diff --git a/services/platform/backend/domains/erasure/service.ts b/services/platform/backend/domains/erasure/service.ts index bb2f7d5da7..3a9f170475 100644 --- a/services/platform/backend/domains/erasure/service.ts +++ b/services/platform/backend/domains/erasure/service.ts @@ -1,10 +1,12 @@ import type { Sql, TransactionSql } from 'postgres'; +import { isRecord } from '../../../lib/utils/type-utils.ts'; import { isAdminRole } from '../../auth/membership.ts'; import { ERASURE_REASON_CODES, ERASURE_WATCHDOG_TIMEOUT_MESSAGE, } from '../../core/governance/erasure_constants.ts'; +import { normalizeAuthEmail } from '../../core/lib/auth/normalize_auth_email.ts'; import { parseBlobRef } from '../../core/lib/storage/blob_ref.ts'; import { toJson } from '../../db/sql.ts'; import { addJobInTx } from '../../jobs/enqueue.ts'; @@ -32,6 +34,12 @@ import { writeNotificationForOrgs } from '../notifications/service.ts'; * the receipt + the gdpr audit rows — rule 5). */ +/** Stands in for the subject on a record that is kept but de-identified. A + * review decision is the audit trail of a governance gate, so the row stays + * and the identity goes. Same value 0.4 used, so old and new rows read + * alike. */ +const ERASED_SUBJECT = 'erased-user'; + const HOUR_MS = 60 * 60 * 1000; const DAY_MS = 24 * HOUR_MS; const SLA_DAYS = 30; @@ -551,6 +559,37 @@ async function scrubSubjectAuditLogs( return scrubbed.length; } +/** + * Does the subject still belong to another organization that has not + * disabled them? + * + * `login_attempts`, `login_block_counters` and the two-factor tables are + * keyed globally, by email or by user id, while a GDPR request is scoped to + * one organization. Wiping them for one org's request would reset the + * lockout and 2FA backoff counters protecting every OTHER org the subject + * belongs to, which hands a multi-org user a cross-tenant bypass. 0.4 + * refused the wipe in that case; so does this. + * + * 0.4 paged Better Auth's adapter at 256 memberships and failed CLOSED when + * the page came back full, because it could not see past the cap. SQL + * answers exactly, so neither the cap nor its guard is ported. + */ +async function subjectBelongsToOtherActiveOrg( + sql: Sql, + userId: string, + excludeOrgId: string, +): Promise { + const rows = await sql<{ elsewhere: boolean }[]>` + SELECT EXISTS ( + SELECT 1 FROM "member" + WHERE "userId" = ${userId} + AND "organizationId" <> ${excludeOrgId} + AND "role" <> 'disabled' + ) AS "elsewhere" + `; + return rows[0]?.elsewhere ?? false; +} + /** The cascade — each pass bounded and idempotent; per-pass counts land on * the receipt. Reuses the retention purge primitives for threads and the * document trash for owned documents. */ @@ -733,6 +772,200 @@ export async function processErasure( return removed.length; }); + // `video_link_jobs` is its own category, not part of `uploads`: the job can + // own a blob (Whisper audio, captions transcript) even when the linked + // `file_metadata` row never landed, and a welcome-page paste has no thread, + // so neither the uploads pass nor the thread cascade reaches it. + // + // Same STRICT blob-then-row posture the uploads pass above uses, and for + // the same reason: a receipt that says done is a claim about the bytes. + await pass('videoLinks', async () => { + const jobs = await sql<{ id: string; storageRef: string | null }[]>` + SELECT id, storage_ref AS "storageRef" FROM app.video_link_jobs + WHERE org_id = ${organizationId} AND uploaded_by = ${targetUserId} + `; + if (jobs.length === 0) return 0; + const withBlobs = jobs.filter( + (job) => job.storageRef !== null && job.storageRef !== '', + ); + let orgSlug: string | null = null; + if (withBlobs.length > 0) { + orgSlug = await resolveOrgSlug(sql, organizationId); + if (orgSlug === null) { + throw new Error( + `no org slug for ${organizationId}; video-link blobs not deletable`, + ); + } + } + const deletedRefs = new Set(); + for (const job of jobs) { + const ref = job.storageRef; + if (ref !== null && ref !== '' && orgSlug !== null) { + const parsed = parseBlobRef(ref); + if (parsed.backend === 's3' && !deletedRefs.has(ref)) { + const store = await resolveObjectStore(orgSlug); + await s3DeleteObject(store, parsed.key); + deletedRefs.add(ref); + } + } + await sql`DELETE FROM app.video_link_jobs WHERE id = ${job.id}`; + } + return jobs.length; + }); + + // The subject's own OAuth grant for Documents import — a sealed access + + // refresh token per (org, user, provider). It outlives their membership + // unless erasure takes it, and an in-flight authorization carries the same + // identity in its state row. + await pass('cloudGrants', async () => { + const grants = await sql<{ id: string }[]>` + DELETE FROM app.user_cloud_authorizations + WHERE org_id = ${organizationId} AND user_id = ${targetUserId} + RETURNING id + `; + const states = await sql<{ id: string }[]>` + DELETE FROM app.cloud_import_oauth_states + WHERE org_id = ${organizationId} AND user_id = ${targetUserId} + RETURNING id + `; + return grants.length + states.length; + }); + + // Sync configs name the member whose grant the sync runs under, so they are + // subject data AND a schedule that would keep firing against a revoked + // grant. Imported documents are not touched here — they are org content, + // reached by the `documents` pass when the subject created them. + await pass('syncConfigs', async () => { + const onedrive = await sql<{ id: string }[]>` + DELETE FROM app.onedrive_sync_configs + WHERE org_id = ${organizationId} AND user_id = ${targetUserId} + RETURNING id + `; + const googleDrive = await sql<{ id: string }[]>` + DELETE FROM app.google_drive_sync_configs + WHERE org_id = ${organizationId} AND user_id = ${targetUserId} + RETURNING id + `; + return onedrive.length + googleDrive.length; + }); + + // The org-level security and system bells ABOUT the subject, which are a + // different table from the per-user inbox the `notifications` pass above + // clears. `subject_user_id` exists for exactly this — 0002_notifications + // calls it "the data-subject user this notification is ABOUT (GDPR Art 17 + // erasure matches on it)" — and the lockout alert stamps it on the row + // that carries the subject's email and IP in `params`. `notification_reads` + // falls with the row on its foreign key. + await pass('orgNotifications', async () => { + const removed = await sql<{ id: string }[]>` + DELETE FROM app.notifications + WHERE org_id = ${organizationId} + AND subject_user_id = ${targetUserId} + RETURNING id + `; + return removed.length; + }); + + // Automation runs the subject started. `input`, `output`, `trace` and + // `effects` hold every node's resolved values, so the run is subject data + // even though the row is org-owned. The two markers are the ones 0.5 + // writes: `user:` from the app door and `api-key:` from REST. + await pass('automationRuns', async () => { + const removed = await sql<{ id: string }[]>` + DELETE FROM app.automation_runs + WHERE org_id = ${organizationId} + AND started_by = ANY(${[`user:${targetUserId}`, `api-key:${targetUserId}`]}) + RETURNING id + `; + return removed.length; + }); + + // Review decisions are pseudonymized rather than deleted: the decision is + // the audit record of a governance gate, so the row stays and the identity + // goes. `tasks.reviewer_user_id` is different — it is live routing, not + // history, so it is cleared. Leaving it pointed at an erased user sends + // the next review to nobody. + await pass('reviewDecisions', async () => { + const decisions = await sql< + { id: string; approvedBy: string | null; metadata: unknown }[] + >` + SELECT id, approved_by AS "approvedBy", metadata + FROM app.approvals + WHERE org_id = ${organizationId} AND resource_type = 'task_review' + AND (approved_by = ${targetUserId} + OR metadata->>'requestedFor' = ${targetUserId} + OR metadata->'response'->>'respondedBy' = ${targetUserId}) + `; + let changed = 0; + for (const row of decisions) { + const metadata = isRecord(row.metadata) ? { ...row.metadata } : undefined; + if (metadata !== undefined) { + if (metadata.requestedFor === targetUserId) { + metadata.requestedFor = ERASED_SUBJECT; + } + const response = metadata.response; + if (isRecord(response) && response.respondedBy === targetUserId) { + metadata.response = { ...response, respondedBy: ERASED_SUBJECT }; + } + } + await sql` + UPDATE app.approvals SET + approved_by = ${row.approvedBy === targetUserId ? ERASED_SUBJECT : row.approvedBy}, + metadata = ${metadata === undefined ? null : sql.json(toJson(metadata))} + WHERE id = ${row.id} + `; + changed++; + } + const cleared = await sql<{ id: string }[]>` + UPDATE app.tasks SET reviewer_user_id = NULL + WHERE org_id = ${organizationId} + AND reviewer_user_id = ${targetUserId} + RETURNING id + `; + return changed + cleared.length; + }); + + // Global auth state: the lockout trail is keyed by email and the two-factor + // backoff by user id, so neither is org-scoped. Refused outright while the + // subject is still an active member elsewhere, because these counters + // protect those organizations too. + await pass('authState', async () => { + if ( + await subjectBelongsToOtherActiveOrg(sql, targetUserId, organizationId) + ) { + console.warn( + `[erasure] skipping global auth-state wipe for ${targetUserId}: still an active member of another organization`, + ); + return 0; + } + const users = await sql<{ email: string | null }[]>` + SELECT "email" FROM "user" WHERE "id" = ${targetUserId} LIMIT 1 + `; + const email = users[0]?.email ?? null; + let removed = 0; + if (email !== null) { + const normalized = normalizeAuthEmail(email); + const attempts = await sql<{ email: string }[]>` + DELETE FROM app.login_attempts WHERE lower(email) = ${normalized} + RETURNING email + `; + const counters = await sql<{ email: string }[]>` + DELETE FROM app.login_block_counters WHERE lower(email) = ${normalized} + RETURNING email + `; + removed += attempts.length + counters.length; + } + const twoFactor = await sql<{ userId: string }[]>` + DELETE FROM app.two_factor_attempts WHERE user_id = ${targetUserId} + RETURNING user_id AS "userId" + `; + const grace = await sql<{ userId: string }[]>` + DELETE FROM app.two_factor_grace WHERE user_id = ${targetUserId} + RETURNING user_id AS "userId" + `; + return removed + twoFactor.length + grace.length; + }); + await pass('auditScrub', () => scrubSubjectAuditLogs(sql, organizationId, targetUserId), ); diff --git a/services/platform/backend/integration-check.ts b/services/platform/backend/integration-check.ts index b67dbf9ad0..e584a48476 100644 --- a/services/platform/backend/integration-check.ts +++ b/services/platform/backend/integration-check.ts @@ -26322,6 +26322,59 @@ async function checkErasure( reason: 'self test', reasonCode: 'consent_withdrawn', }); + // The seven categories the cascade gained. None is reachable from the + // thread, document, preference or upload passes: two are org-level rows + // ABOUT the subject, two are global auth state keyed by email or user id, + // and the review decision is KEPT and de-identified rather than deleted. + await sql` + INSERT INTO app.notifications ( + org_id, category, severity, title_key, body_key, params, + subject_user_id, created_at_ms + ) VALUES (${orgId}, 'security', 'warning', 'accountLocked', + 'lockoutDetails', + ${sql.json({ email: `${subject}@example.com`, ip: '203.0.113.9' })}, + ${subject}, ${now}) + `; + await sql` + INSERT INTO app.automation_runs ( + org_id, name, version, status, mode, started_by, started_at_ms + ) VALUES (${orgId}, 'erasure-fixture', 1, 'success', 'live', + ${`user:${subject}`}, ${now}) + `; + await sql` + INSERT INTO app.approvals ( + org_id, resource_type, resource_id, status, approved_by, metadata, + created_at_ms + ) VALUES (${orgId}, 'task_review', ${`er-task-${subject}`}, 'completed', + ${subject}, + ${sql.json({ requestedFor: subject, response: { respondedBy: subject } })}, + ${now}) + `; + await sql` + INSERT INTO app.login_attempts (email, consecutive_failures, + last_failure_at) + VALUES (${`${subject}@example.com`}, 3, ${now}) + ON CONFLICT (email) DO NOTHING + `; + await sql` + INSERT INTO app.two_factor_attempts (user_id, consecutive_failures, + last_failure_at_ms) + VALUES (${subject}, 2, ${now}) ON CONFLICT (user_id) DO NOTHING + `; + await sql` + INSERT INTO app.user_cloud_authorizations ( + org_id, user_id, provider, encrypted_data, status, created_at_ms, + updated_at_ms + ) VALUES (${orgId}, ${subject}, 'google-drive', + ${sql.json({ sealed: 'x' })}, 'active', ${now}, ${now}) + `; + await sql` + INSERT INTO app.google_drive_sync_configs ( + org_id, user_id, item_type, item_id, item_name, target_bucket, status, + created_at_ms, updated_at_ms + ) VALUES (${orgId}, ${subject}, 'folder', ${`gd-${subject}`}, 'Reports', + 'documents', 'active', ${now}, ${now}) + `; const filed = z .object({ requestId: z.string(), threadsTargeted: z.number() }) .loose() @@ -26356,8 +26409,29 @@ async function checkErasure( WHERE org_id = ${orgId} AND user_id = ${subject}) + (SELECT count(*) FROM app.memories WHERE org_id = ${orgId} AND user_id = ${subject}) + + (SELECT count(*) FROM app.notifications + WHERE org_id = ${orgId} AND subject_user_id = ${subject}) + + (SELECT count(*) FROM app.automation_runs + WHERE org_id = ${orgId} AND started_by = ${`user:${subject}`}) + + (SELECT count(*) FROM app.login_attempts + WHERE email = ${`${subject}@example.com`}) + + (SELECT count(*) FROM app.two_factor_attempts + WHERE user_id = ${subject}) + + (SELECT count(*) FROM app.user_cloud_authorizations + WHERE org_id = ${orgId} AND user_id = ${subject}) + + (SELECT count(*) FROM app.google_drive_sync_configs + WHERE org_id = ${orgId} AND user_id = ${subject}) )::text AS count `; + // A review decision is KEPT and de-identified: the decision is the audit + // record of a governance gate, so the row stays and the identity goes. + const reviewErased = await sql<{ count: string }[]>` + SELECT count(*)::text AS count FROM app.approvals + WHERE org_id = ${orgId} AND resource_id = ${`er-task-${subject}`} + AND approved_by = 'erased-user' + AND metadata->>'requestedFor' = 'erased-user' + AND metadata->'response'->>'respondedBy' = 'erased-user' + `; const scrubbed = await sql<{ count: string }[]>` SELECT count(*)::text AS count FROM app.audit_logs WHERE org_id = ${orgId} AND resource_type = 'user' @@ -26469,6 +26543,7 @@ async function checkErasure( receiptStatus === 'done' && threadGone[0]?.count === '0' && leftovers[0]?.count === '0' && + reviewErased[0]?.count === '1' && Number(scrubbed[0]?.count ?? '0') >= 1 && filedTwo.success && cancelled.success && @@ -26480,7 +26555,7 @@ async function checkErasure( filedThree.data.userCustodianHeld && blockedReceipt[0]?.status === 'blocked' && threeStatus === 'done', - `self=${selfRefused.status} (want 403), receipt=${receiptStatus}, thread=${threadGone[0]?.count}, leftovers=${leftovers[0]?.count}, scrubbed=${scrubbed[0]?.count}, cancel=${cancelled.success ? cancelled.data.ok : 'ERR'}, twoIntact=${twoIntact[0]?.count}, blocked=${blockedRes.status}/${filedThree.success ? filedThree.data.error : 'ERR'}/${blockedReceipt[0]?.status ?? '?'} (want 409/LEGAL_HOLD_BLOCKS_ERASURE/blocked), retried=${threeStatus}`, + `self=${selfRefused.status} (want 403), receipt=${receiptStatus}, thread=${threadGone[0]?.count}, leftovers=${leftovers[0]?.count}, reviewDeidentified=${reviewErased[0]?.count} (want 1), scrubbed=${scrubbed[0]?.count}, cancel=${cancelled.success ? cancelled.data.ok : 'ERR'}, twoIntact=${twoIntact[0]?.count}, blocked=${blockedRes.status}/${filedThree.success ? filedThree.data.error : 'ERR'}/${blockedReceipt[0]?.status ?? '?'} (want 409/LEGAL_HOLD_BLOCKS_ERASURE/blocked), retried=${threeStatus}`, ); // ---- the documented 'child' reason code files -------------------------