Repository navigation
fix(account-deletion): durably purge a deleted account's storage without touching live objects - #9146
Conversation
… prefix Account deletion found the objects to erase by reading their rows before the teardown transaction and deleting them in memory after commit. Anything that read could not see stayed in storage forever: rows written between the read and the workspace lock, rowless objects (table snapshots, compiled documents, table-import leftovers, execution objects whose metadata insert failed), everything past the 100k in-memory cap, everything after a crash following commit, and the account's private files in workspaces that survive it, whose rows cascade away with the user. Inside the deletion transaction, after the workspace rows are locked, account deletion now queues delayed outbox work that commits or rolls back with it: - one prefix sweep per deleted workspace across workspace/, table-import/, execution/, table-snapshots/, copilot-doc-compiled/ and workspace-logos/ in the bucket serving each, with a trailing-slash prefix built from a validated id; - exact-key cleanup for what cascades outside those prefixes: knowledge-base documents, Copilot uploads and other non-prefixed files of deleted workspaces, and the account's private files elsewhere (attachments of its own chats, private contexts, files with no workspace). Handed-off, Project, organization files and attachments of someone else's chat are not queued. Both wait out every upload URL signed before the deletion, refuse as a no-op when the workspace or account row exists again (restore, PITR), and keep any key a surviving workspace_files, workspace_file, workspace_file_version, document or execution_large_values row still references. The sweep reuses the Project prefix cleanup's provider listing and deletion, generalized by storage context and given an in-run provider cursor; Project cleanup still lists one page per run and deletes exactly as before. The existing in-memory purge is unchanged and still runs. Large execution values now pass cleanupOnMetadataFailure, so a failed metadata insert deletes the freshly keyed object instead of orphaning it, as execution files already do.
Review of the durable storage sweep found ways it, or the in-memory purge it backs up, could delete an object another tenant still uses. Reference guard. Every account-deletion purge now runs one check before deleting a key: the durable prefix sweep (per listed key), the durable exact-key cleanup, and the post-commit in-memory purge. A key is kept when a row still names it (workspace_files.key, workspace_file.key, workspace_file_version.key, document.storage_key, execution_large_values.key) or a stored asset URL names it: user.image, workspace.logo_url, organization.logo, organization.whitelabel_settings, organization.metadata, custom_block.icon_url, chat.customizations (deployed-chat logo and avatar) and oauth_client.icon. URLs are mapped to keys with extractStorageKey, so serve paths with or without a provider segment and direct bucket URLs both match; over-matching only keeps an object. References embedded in free-form JSON or text (workflow sub-block values, chat messages, Markdown links) are not consulted; for the deleted account's own chat attachments that is the intended erasure. This keeps whitelabel logos that were uploaded under an admin's personal workspace prefix, and legacy kb/ objects shared across workspaces, which the in-memory purge used to delete unchecked. Exact-key cleanup also keeps any key under a workspace-keyed root whose workspace still exists, since its members can read it by key through the metadata fallback. Workspace-less rows are queued only for the Copilot context: the workspace binding check was added NOT VALID, so legacy shared-context rows without a workspace can name a surviving workspace's object. Profile pictures are taken only from the account's own user.image, never from rows, because whitelabel logos were once stored as profile-pictures rows under an admin's account; organization logos are never queued either. Prefix deletion now counts only objects actually removed. A tolerated ENOENT (locally, any file whose on-disk name no sanitized key resolves to) used to count as progress, so a page of such files re-listed forever without spending an attempt. The workspace sweep and the Project prefix cleanup (which had the same flaw) now defer and spend an attempt when a full page removes nothing. A local metadata sidecar is kept whenever its object is. Versions of the account's private files are queued in keyset pages that stop at the cap instead of one unbounded query inside the deletion transaction.
A stored URL with malformed percent-encoding (for example `/api/files/serve/%E0%A4%A`, which any workspace admin can store as a logo URL) made extractStorageKey throw. The guard then rejected on every call, so every storage purge on the platform failed closed and deleted nothing. The guard now parses each value under its own try and falls back to the raw, undecoded path. extractStorageKey itself is unchanged for its other callers. The URL-column check read six unindexed tables on almost every guard call. Only keys under the prefixes the file-serve route answers without a row or a session (profile-pictures/, og-images/, workspace-logos/, organization-logos/, per isPublicByKeyPrefix in the serve route) can be live through a stored URL alone. The guard now reads URL columns only for such keys, and only rows containing the key's longest URL-safe run, which percent-encoding never splits. Each run is read at most once per guard, 50 per query. A key with no run long enough falls back to the unfiltered read. Lookup failures still throw, so nothing is deleted. The workspace sweep now defers with a distinct reason when a full local page removes nothing, so the test for it can tell a stall from a deadline.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
There was a problem hiding this comment.
All reported issues were addressed across 11 files
This PR changes concurrency-sensitive code such as locks, queues, or retries. Ultrareviews find 2.4x more serious bugs than standard reviews. Comment @cubic-dev-ai ultrareview to run one.
Turn on auto-fix | Re-trigger cubic
[High impact] The latest changes appear safe to merge; no new blocking issue was found. SummaryAdds delayed, durable storage cleanup to account deletion while keeping objects that surviving rows, workspaces, or saved asset URLs still use.
|
…re storage primitives - keysNamedByUrl also yields a leniently decoded candidate when a stored URL's percent-encoding is malformed, so a legacy key holding a literal '%' is kept - correct the guard's TSDoc: rowless keys stay reachable through key paths under a live workspace and object metadata, not only through a row - move the reference guard to lib/users/storage-reference-guard.ts - move prefix listing/deletion, the upload-TTL cleanup delay, and the outbox deadline margin from lib/projects/files/prefix-cleanup.ts to lib/uploads/core/prefix-sweep.ts; drop the projectUploadCleanupAvailableAt alias - share WORKSPACE_SHARED_FILE_CONTEXTS between member handoff and account cleanup - isPublicStorageKey replaces the public-prefix lists in the serve route and guard
…locks - collectAccountStorageObjects runs the exact-key keyset reads before the transaction, beside collectAccountStorageKeys; inside it, enqueueAccountStorageCleanupInTx now only inserts the prefix-sweep events and the pre-collected exact-key events, so teammates' writes in shared workspaces never wait on those reads - Project prefix cleanup completes on a short listing page again, as before - the file-serve route keeps its original public-prefix check
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 19 files
Reply with feedback, questions, or to request a fix.
Turn on auto-fix | Re-trigger cubic
- integration fixture rows get unique names: the active-name unique index on workspace_files rejected the second 'fixture.txt' row, failing seed() - the reference guard reads stored asset URLs unfiltered and decodes them, so a URL that percent-encodes the key's characters still keeps its object; the world-readable-key gate still decides whether any URL column is read - enqueueAccountStorageCleanupInTx re-reads the account's rows written since the pre-transaction collection (via the user_id and uploaded_by indexes) under the account lock, so a late Copilot or knowledge-base upload is queued - local listings walk keys in sorted order and page after the last key, so a workspace sweep advances past a full page of kept objects
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
1 issue found and verified against the latest diff
Confidence score: 2/5
- In
sweepWorkspaceStorage, the one-time workspace existence check leaves a race: if the workspace is restored during the sweep, later deletions can remove restored or newly uploaded objects. Recheck the workspace state before deleting objects so the sweep is safe against a concurrent restore.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/sim/lib/users/storage-sweep.ts">
<violation number="1" location="apps/sim/lib/users/storage-sweep.ts:277">
P1: `sweepWorkspaceStorage` checks whether the workspace exists only once, before sweeping all families and pages. If the workspace is restored after that read, the handler can delete its restored and newly uploaded objects; re-check the workspace immediately before each destructive page/batch or serialize the sweep with restoration.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Turn on auto-fix | Re-trigger cubic
…s per page - fix TS2769: the collection scope passed a readonly id list to notInArray - the reference guard reads stored asset URL rows only where they mention a public key's owning id run, paged, and keeps a public key with no such run instead of reading every URL column - the account deletion request no longer purges world-readable keys; the account's own avatar is queued for the delayed durable cleanup instead - the workspace prefix sweep re-checks that the workspace is still gone before every page it deletes from
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 20 files
Reply with feedback, questions, or to request a fix.
Turn on auto-fix | Re-trigger cubic
user.image accepts any serve URL, so it names a key without proving whose it is. The avatar is queued for durable cleanup only when the account's completed profile_picture upload session, or an older profile-picture workspace_files row of the account, names the same key; otherwise it is left in place.
- the reference guard keeps keys under a live workspace's keyed roots itself, so the request purge gets the check the durable cleanup already had - stored asset URLs are re-read for every guard call rather than cached for the sweep, so a reference written mid-sweep still keeps its object - an avatar key that is not a safe profile-pictures key is never queued
…eep shared ones An ownership lookup left avatars uploaded more than a week before deletion in storage, since their upload sessions are gone by then. The key user.image names is queued again, and the run-time reference guard keeps it whenever another account's user.image or a stored asset URL still names it.
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
No issues found across 21 files
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.
Turn on auto-fix | Re-trigger cubic
Summary
Account deletion collects storage keys before its teardown transaction and purges them in memory after commit. Objects could survive an erasure request indefinitely, even though the privacy policy promises Account Data is removed within 30 days of a deletion request. The ways an object escaped:
Durable, delayed cleanup enqueued inside the deletion transaction (after the existing workspace
FOR UPDATE; no new locks):workspace/,table-import/,execution/,table-snapshots/,copilot-doc-compiled/andworkspace-logos/, each followed by<id>/.lib/projects/files/prefix-cleanup.ts; Project behavior is unchanged.document.storage_key,copilot/<uuid>/, and the user's private files and versions whose rows cascade with the user.copilot.user.image.Safety guards: a key is kept, never deleted, if any of these hold:
<id>/prefix (ids are validated; prefixes always end in/);workspace_files.key,workspace_file.key,workspace_file_version.key,document.storage_keyorexecution_large_values.key;user.image,workspace.logo_url,custom_block.icon_url,chat.customizations,oauth_client.icon, ororganization.logo/whitelabel_settings/metadata.Further rules:
Also:
kb/keys.cleanupOnMetadataFailure.Deliberately left behind (never live-data loss): legacy objects under
profile-pictures/,organization-logos/orog-images/thatworkspace_filesrows in a deleted workspace name are no longer deleted, either in the deletion request or by the durable cleanup. Whitelabel logos were once uploaded as profile-picture rows, so such an object may still be served publicly through a surviving organization's settings; these objects are left orphaned rather than risk deleting live data. Logos under the deleted workspace's ownworkspace-logos/<id>/prefix are still swept. The account's own avatar (the key itsuser.imagenames) is still deleted, unless another account or a stored asset URL still names it.Type of Change
Testing
Unit tests on real local storage cover:
ws1vsws10);Each guard was reverted and its test went red.
lib/users/storage-sweep.integration.ts(real Postgres) runs the realdeleteUserAccount:Three independent adversarial reviews (data-loss hunt, regression/DB-load, URL-guard soundness) found no remaining live-data deletion path.
Checklist