Purpose
Signed storage URLs are bearer tokens, and Supabase logs do not identify the app actor behind a service-role request. Without an app-side record, an exposure review cannot answer who obtained which object, when, and through which route.private.storage_access_events closes that gap for the workspaces bucket.
What is recorded
Each row is one access decision:- File names, query strings, IP addresses, user agents, emails and signed URLs are never stored.
- Folder and file names are omitted for every instrumented route, even without a hash key. Object and actor identifiers remain linkable audit data; restrict access accordingly. Regression coverage:
storage-access-audit.test.tsincludes synthetic email folder names with valid, absent and short keys. - To check whether a known object was touched, compute the HMAC of its full
name (
<wsId>/<path>) with the audit key and compare it withobject_hash.
Object hash key
object_hash is keyed so that a reader of the table cannot confirm guessed
file names. An unkeyed SHA-256 of <wsId>/<path> could be tested against
candidate names.
- Web reads the key from the environment variable
STORAGE_ACCESS_AUDIT_HMAC_KEY. Use at least 32 random bytes and keep it in the deployment secret store. Never commit it, log it, or paste it into tickets or chat. - If the key is missing or shorter than 32 bytes, rows store only the opaque folder marker and object id. The writer never falls back to an unkeyed hash.
- Rotating the key makes older rows unmatchable with the new key. Keep the previous key in the secret store for the 90-day retention window if lookups of older rows matter.
Instrumented routes
Other storage routes are not instrumented yet. API-key requests rejected by
withApiAuth before the handler runs are not recorded. Add a route with
withStorageAccessAudit or auditStorageAccess from
apps/web/src/lib/storage/storage-access-audit.ts.
Write guarantees and limits
The writer is@tuturuuu/storage-core/storage-access-audit.
- Best effort.
record()returns immediately and never throws. A failed insert is logged withconsole.warnand dropped. The user request never waits for or fails because of the audit. - Bounded per instance:
- at most 100 rows per call, which covers the largest SDK batch of 100 paths;
- at most 32 concurrent writes (extra events are dropped and counted);
- identical
allowedevents from one instance are de-duplicated for 60 seconds, so repeated image loads do not flood the table. Denials and errors are always written.
- Losable. Vercel Node functions have no
waitUntilbinding here, so a write can be lost if an instance is frozen right after responding. Treat the log as strong evidence when present, not as proof of absence.
Retention
- Rows older than 90 days are deleted by
private.prune_storage_access_events(p_limit), at most 1,000 rows per call. - The default-off Cron Control job
database-storage-access-audit-retentioncalls it on the ten-minute trigger. - There is no pg_cron schedule or Next route; see Cron Control.
Who can read it
- RLS is enabled with no policies, so
anonandauthenticatedcan neither read nor write the table. service_rolecan insert and select, but not update or delete. Only the retention function deletes.- There is no product UI or API. Operators with database access query it for incident review.
psql (-v, -c or SQL text).
psql inlines variables into the statement, so the key would appear in ps
output and in server statement and error logs.
-
Make the key available without echoing it. Either load
STORAGE_ACCESS_AUDIT_HMAC_KEYinto the shell environment from the secret store, or pointAUDIT_KEY_FILEat a mode-600 file that holds exactly the key. -
Compute the digest. The object name goes on stdin and the key never appears
on a command line:
The name must match the stored object exactly (
<wsId>/<relative path>, and for listings a trailing/). -
Query with the digest, which is not secret. Run it with an operator role:
Rollout
- Apply
20261029040810_storage_access_audit_events.sql. It needs approval and ships in the next migration batch. - Set
STORAGE_ACCESS_AUDIT_HMAC_KEY(a secret of at least 32 random bytes), thenSTORAGE_ACCESS_AUDIT_ENABLED=true, for the Web deployment. Until then the writer is a no-op, so the code is safe to deploy before the migration. - Enable
database-storage-access-audit-retentioninCRON_CONTROL_ENABLED_JOBS.
Schema rollback
Prefer disabling the writer and retention job while retaining audit evidence. The rollback file inapps/database/rollbacks/20261029040810_storage_access_audit_events.rollback.sql
removes the table and retention function. It deletes audit history: disable both
writers and archive required evidence before an explicitly authorized schema reversal.
Re-applying the migration recreates an empty, service-only audit table.