fix(platform): erase the subject's cloud grants, sync configs and blobs - #3122
Conversation
|
Extended, and rescoped around #3135 to stop us colliding. Dropped from this PR: the Added, after an audit of all 19 of 0.4's
The last two are global rather than org-scoped, so they are refused while the subject is still an active member of another organization — those counters protect those organizations too, and wiping them on one org's request would hand a multi-org user a lockout bypass. 0.4 had that gate; it paged Better Auth at a 256-membership cap and failed closed on truncation. SQL answers exactly, so neither the cap nor the guard is ported. Review decisions are de-identified rather than deleted, to the same Video-link blobs now delete strictly, matching #3135's argument rather than the retention idiom. Tests
Observed against a throwaway Postgres, driving
The eighth is an invariant, not a regression probe: it asserts the approvals ROW is still present, which holds on both sides. ScopeFour findings from the same audit are deliberately not here, because they are different concerns and two of them are #3135's:
Gate: |
0e2a6f4 to
fe7faf3
Compare
0.4 ran 19 eraseSubject arms; the 0.5 cascade runs 10 passes. Seven categories of subject data survived a request that reported done: - app.notifications, the ORG-level bells about the subject — a different table from the per-user inbox the notifications pass clears. subject_user_id was added for this and never read, and the lockout alert stamps it on the row carrying the subject's email and IP. - app.automation_runs they started, whose input, output, trace and effects hold every node's resolved values. - app.approvals and app.tasks.reviewer_user_id. The decision is KEPT and de-identified — it is the audit record of a governance gate — while the reviewer pointer is live routing and is cleared. - app.login_attempts and app.login_block_counters, keyed by email. - app.two_factor_attempts and app.two_factor_grace, keyed by user id. - app.user_cloud_authorizations and app.cloud_import_oauth_states, the subject's sealed OAuth grant for Documents import. - app.onedrive_sync_configs and app.google_drive_sync_configs, which name the member whose grant the sync runs under. - app.video_link_jobs, which can own a blob even when the linked file_metadata row never landed. The auth tables are global rather than org-scoped, so they are refused while the subject is still an active member elsewhere: those counters protect those organizations too, and wiping them on one org's request hands a multi-org user a lockout bypass. 0.4 paged Better Auth at a 256-membership cap and failed closed on truncation; SQL answers exactly. Video-link blobs delete strictly, following the uploads pass #3140 landed rather than the retention sweeps' best-effort idiom: a receipt that says done is a claim about the bytes. Refs #3120.
fe7faf3 to
9441882
Compare
|
Re-applied onto current What #3135 and #3140 already fixed, so it is no longer here: the What is still needed, verified pass-by-pass against The TestsExtended Run against a real Postgres and MinIO: 369/375, with the erasure check passing — The six failures are the warm-MinIO bucket collision, two The test caught two of my own fixture bugs before it caught anything else, which is worth recording. First run: Still not hereFour findings from the same audit, all separate concerns and tracked in #3142: the legal hold is checked once rather than per pass, the receipt cannot distinguish "found nothing" from "never looked", the |
A GDPR erasure now removes the subject's cloud-import OAuth grant, their Drive and OneDrive sync configs, their video-link jobs, and the blobs behind their bare uploads. All of those survived a request that reported
done.Closes #3120.
Why
The 0.4 cascade ran 19
eraseSubject*arms. The 0.5 cascade runs 10 passes. Six categories had no pass at all:app.user_cloud_authorizationsapp.cloud_import_oauth_statesapp.onedrive_sync_configsapp.google_drive_sync_configsapp.video_link_jobsThe token row is the sharpest.
0042_cloud_import.sqldescribes it as an intentional Documents-import grant, sealed with the same envelope connector credentials use. It outlives the subject's membership.The blob is the same class of miss.
purgeDocumentdeletes the corpus entry and then the blob; theuploadspass deleted only the row.What changed
purgeBlobArtefactsinretention/service.ts— the corpus-then-blob pair lifted out ofpurgeDocument, which now calls it. One implementation, two callers.uploadspurges each file's artefacts before deleting its row.videoLinks,cloudGrants,syncConfigs.video_link_jobsgets its own pass rather than folding intouploads: the job can own a blob when the linkedfile_metadatarow never landed, and a welcome-page paste carries no thread, so neither the uploads pass nor the thread cascade reaches it.Sync configs go because they name the member whose grant the sync runs under. Documents those syncs imported stay; that is org content, reached by the
documentspass when the subject created it.Risk
Each new pass loops with a knowledge-database round trip per row, where
uploadswas a single statement. Thedocumentspass directly above it already has that shape, and the cascade runs as a background job.Tests
integration-check.tsseeds all six categories for the erased subject and counts them intoleftovers, which the existing assertion requires to be0.Observed against a throwaway Postgres, driving
processErasuredirectly:done; countsuploads:1 videoLinks:1 cloudGrants:2 syncConfigs:2origin/maindoneThe blob purge itself is not asserted. Observing it needs MinIO, which would not pull in this environment. It rides
purgeBlobArtefacts, the same pathpurgeDocumentalready uses under CI's S3 lanes.Scope
Four 0.4 arms still have no 0.5 pass:
eraseSubjectPolicyAcknowledgements,eraseSubjectTwoFactorAttempts,eraseSubjectAutomationRunsanderaseSubjectWfExecutions. Their 0.5 tables need checking before anyone claims cascade parity. #3120 records that.Gate:
typecheck,oxlint --type-aware,oxfmt --checkandlint:sastgreen.