fix(storage): drop the client-side DELETE policy on the documents bucket (#1254)
* fix(storage): drop the client-side DELETE policy on the documents bucket
20240101000024 documents this bucket as WORM: "No UPDATE or DELETE policies".
That described the repo, not production. Production carries a
users_delete_own_documents policy that exists in no migration file:
FOR DELETE TO authenticated
USING (bucket_id = 'documents'
AND (storage.foldername(name))[2] = auth.uid()::text)
Under it, the uploading user can delete the storage bytes of any document
they uploaded under the legacy documents/{userId}/... layout, using nothing
but their normal browser token. That includes documents linked to a posted
verifikat, which are rakenskapsinformation under the BFL 7 kap 2 § seven-year
retention duty. deleteDocument()'s linked-check and the
block_document_deletion() trigger both guard the document_attachments ROW,
not the object: the row survives, still pointing at a file that is gone.
Reproduced against a local replay of the full migration stream: with the
policy present the uploader's own DELETE removes the object; with it dropped
the same statement matches zero rows. Company-scoped keys were never exposed
(their second path segment is the company id, not auth.uid()), so this only
ever reached the legacy layout, which is where most documents still live.
Safe because every in-app remove() on this bucket already runs on the service
role, covered by service_role_all_documents.
Deliberately narrow: users_read_own_documents and users_upload_own_documents
stay. The Phase B backfill from 20260726092000 has not run, so dropping the
legacy SELECT policy now would make existing documents unreadable. That is
Phase C.
The pg-real test asserts no DELETE and no UPDATE policy over the bucket under
ANY name: the hole arrived under a name this repo never used, so pinning a
name would not have caught it.
Refs #1208
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(storage): make the WORM ratchet see FOR ALL and WITH CHECK policies
Review caught two blind spots in the ratchet, both fair. It matched only
polcmd 'd' and 'w', but polcmd '*' (FOR ALL) grants DELETE and UPDATE just as
effectively, and FOR ALL is the shape the one legitimate policy on this table
already uses, so a hostile one would look unremarkable in the catalogue. It
also read only polqual, so an UPDATE policy carrying its bucket restriction in
WITH CHECK was invisible.
Both assertions now run through one helper that covers d/w/*, concatenates
USING and WITH CHECK, and filters by grantee so service_role_all_documents
(how the application does its authorized deletes) is excluded while every
client-reachable role is not. A policy granted to PUBLIC has an empty
polroles, which is the most permissive case there is, so it is treated as
client-reachable rather than as "no roles".
Matching on the substring rather than the exact `bucket_id = 'documents'`
shape pg_get_expr emits today: a policy written as bucket_id::text or with the
comparison reversed would slip past a stricter match, and for a WORM ratchet a
false alarm is cheap while a silent hole is not.
Adds a probe case that creates a FOR ALL policy and asserts the helper sees
it, so the main assertion cannot pass vacuously. That case earned its keep
immediately: it caught that node-postgres hands back a raw string for a name[]
column, so the role filter needed rolname::text to work at all.
Verified against a local replay of the full migration stream: red with the
original prod FOR DELETE policy present, red with a FOR ALL probe, green
without either. Full pg-real suite 933 passed.
Refs #1208
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(storage): catch a destructive policy that names no bucket at all
Adversarial review of the previous commit found the ratchet still failed
open, and reproduced it: a policy with no bucket_id predicate covers EVERY
bucket, documents included, so gating on the bucket name discarded exactly
the widest hole. The concrete shape is Supabase's own stock "Enable delete for
users based on user_id" template, USING (auth.uid() = owner), which is the
single most likely form of a future dashboard edit.
A destructive policy is now in scope unless it provably cannot reach this
bucket, i.e. only a bucket_id predicate naming some other bucket exempts it.
The behavioural assertions had the matching blind spot: fixtures were seeded
without an owner, so an owner-based policy matched NULL and the DELETE
reported 0 rows for the wrong reason. Objects now carry an owner the way
storage-api stamps them in production, so those tests fail loudly instead of
passing by accident.
Two probes pin both directions: a bucketless policy must be reported (and is
shown to really permit the delete), and a policy scoped to another bucket must
not be, so the ratchet cannot start crying wolf on receipts or sie-files and
get switched off.
Verified against a local replay of the full migration stream: red with the
stock bucketless template installed, green without it. Full pg-real suite 935
passed.
Refs #1208
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
579f48752e
commit
ece6eca922
@@ -630,3 +630,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-27] Docs export (#1247): scripts/export-docs-to-website.mts stubs `server-only` via a Module._load hook instead of untangling the import chain. The chain is real (lib/api/v1/load-routes -> every v1 route -> lib/init -> posthog-observability -> posthog-server) and the script only reads exported markdown builders, so breaking the chain would mean restructuring route imports for a build-time script's benefit.
|
||||
[2026-07-27] Webhook delivery latency (#1201): implemented option (a), an emit-triggered kick of the existing dispatchDueDeliveries, not option (b), an authenticated SSE stream over event_log. The kick is a new lib/webhooks/dispatch-kick.ts wired into fanOutToWebhooks plus the two routes that enqueue a delivery directly (the :test verb and the manual delivery retry), and it does NOT close #1201: the issue asks for a realtime stream for API consumers and that remains open. Three constraints shaped it. It is never awaited: eventBus.emit is awaited at ~99 call sites including journal_entry.committed, and each delivery can burn a 10 s receiver timeout, so awaiting would put a stranger's HTTP endpoint on the critical path of committing a verifikat. It is coalesced per function instance, because a bulk operation emits once per row and would otherwise schedule one claim round trip per row. Its batch size is 5 rather than the cron's 50, because this work runs on the tail of a user-facing request. The SKIP LOCKED claim keeps a kick and the cron from claiming the same row at the same moment, but that is a claim-time guarantee only: the RPC autocommits before any POST, so a later cycle's recoverStuckInFlight sweep can re-arm a row still queued behind an earlier serial loop. Delivery stays at-least-once as the public docs already promise, and the kick's batch of 5 opens a narrower window than the cron's existing batch of 50 against the same 20 s stuck threshold; an early draft of this change claimed double delivery was impossible, which was wrong and is now corrected in the code comments. Scheduling uses next/server after() with a deferred-microtask fallback outside a request scope, mirroring the enable-banking callback; the fallback must stay deferred rather than inline, or the coalescing flag clears before the next kick in the same tick can see it. No API_V1_VERSION bump: no new event types and no payload change, only latency.
|
||||
[2026-07-27] Bolagsskatt add-back (#1051): sumPostedYearEndDispositions now adds back 78xx planenlig avskrivning alongside 88xx and 7533, and excludes fiscal_periods.closing_entry_id from its fetch. Shipping only this "Stage 1" half of the issue: it corrects the tax base and the periodiseringsfond 25 % cap with no migration and no displayed-figure change. The issue's other half (making /rapporter show bokslut entries by moving generateIncomeStatement to excludeFinalClosingEntry) is deliberately NOT done here: it duplicates the exclusion in the kpi_report_aggregates RPC (so it needs a migration plus a pg test), it changes displayed profit for every company that ran the bokslut flow, and it requires removing the add-back at four call sites, including the one that caused the original too-high-tax customer bug. The closing-entry exclusion is part of Stage 1 rather than a follow-up because closing verifikat do carry 78xx/88xx/7533 reversal lines on production, so without it the new add-back silently cancels itself once the year is closed. The issue's stated constraint that source_type='year_end' is load-bearing for the iXBRL RR/BR split is stale: build-input.ts and arsredovisning/build-data.ts already moved to excludeFinalClosingEntry.
|
||||
[2026-07-27] Documents bucket WORM (#1208): dropped the production-only `users_delete_own_documents` DELETE policy on storage.objects and pinned the invariant with a name-agnostic pg-real test, rather than adding a storage DELETE policy for authenticated users or building the orphan-cleanup script the issue asks for. The policy existed in no migration (dashboard drift, alongside `users_read_own_documents` / `users_upload_own_documents`, which production has INSTEAD of this repo's `documents_select_own` / `documents_insert_own`) and let any user delete, with a normal browser token, the storage bytes of documents linked to posted verifikat: rakenskapsinformation under BFL 7 kap 2 §. Neither deleteDocument()'s linked-check nor block_document_deletion() reaches that far; both protect the row, and the row survives pointing at nothing. Verified reproducible against a local replay of the full migration stream: with the policy present the uploader's own DELETE removes a legacy-layout object, with it dropped the DELETE matches zero rows. Dropping it breaks nothing because every in-app remove() on this bucket has run service-role since #1215. The two legacy read/insert policies are deliberately left alone: the Phase B backfill from 20260726092000 has not run, so dropping the legacy SELECT would make most existing documents unreadable. That is Phase C. The 326 orphan objects (49.5 MB) the issue also describes are NOT cleaned up here: irreversible deletion against 7-year-retention data is not worth 49 MB without a separate report-only pass. The test asserts no DELETE and no UPDATE policy over the bucket under ANY name, because the hole arrived under a name this repo never used.
|
||||
|
||||
Reference in New Issue
Block a user