diff --git a/services/platform/backend/integration-check.ts b/services/platform/backend/integration-check.ts index 90377e8227..bec1726ec9 100644 --- a/services/platform/backend/integration-check.ts +++ b/services/platform/backend/integration-check.ts @@ -31757,17 +31757,54 @@ async function checkTwoFactor( // port kept only the failure half, so an attacker who registered their own // passkey and turned TOTP off left no trace at all. Action names are 0.4's // verbatim — a rename would break any saved query grouping on them. + // + // A DEDICATED user, not the harness's own. This block enables 2FA, + // regenerates backup codes, disables it, and registers and removes a + // passkey — so running it on the shared session leaves that user's + // second-factor state changed and invalidates their session. Five later + // checks failed that way: the 2FA grace check, and four chat checks that + // need a live session. + const lifecycleSignUp = await fetch(`${base}/api/auth/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: base }, + body: JSON.stringify({ + email: `itest-2fa-lifecycle-${ctx.orgId.slice(0, 8)}@door.test`, + password: 'itest-password-1', + name: 'Lifecycle', + }), + }); + const lifecycleCookie = cookieHeaderFrom(lifecycleSignUp); + const lifecycleUser = z + .object({ user: z.object({ id: z.string() }) }) + .loose() + .safeParse(await lifecycleSignUp.json()); + const lifecycleUserId = lifecycleUser.success + ? lifecycleUser.data.user.id + : ''; + // Audit rows are org-scoped, so the lifecycle user needs a membership or + // their events have no org to land under and the trail reads empty. + await sql` + INSERT INTO "member" ("id", "organizationId", "userId", "role", + "createdAt") + VALUES (${`m-2fa-${lifecycleUserId}`}, ${ctx.orgId}, ${lifecycleUserId}, + 'member', ${new Date()}) + ON CONFLICT ("id") DO NOTHING + `; const authPost = (route: string, body: unknown): Promise => fetch(`${base}/api/auth${route}`, { method: 'POST', - headers: { 'content-type': 'application/json', cookie, origin: base }, + headers: { + 'content-type': 'application/json', + cookie: lifecycleCookie, + origin: base, + }, body: JSON.stringify(body), }); const lifecycleActions = async (): Promise => { const rows = await sql<{ action: string }[]>` SELECT action FROM app.audit_logs WHERE org_id = ${ctx.orgId} AND resource_type = 'twoFactorAuth' - AND resource_id = ${userId} + AND resource_id = ${lifecycleUserId} ORDER BY ts ASC `; return rows.map((row) => row.action); @@ -31796,7 +31833,7 @@ async function checkTwoFactor( SELECT category, status, (metadata->>'backupCodesRegenerated')::boolean AS regenerated FROM app.audit_logs - WHERE org_id = ${ctx.orgId} AND resource_id = ${userId} + WHERE org_id = ${ctx.orgId} AND resource_id = ${lifecycleUserId} AND action = '2fa_enrolled' AND metadata ? 'backupCodesRegenerated' ORDER BY ts DESC LIMIT 1 `; @@ -31816,7 +31853,7 @@ async function checkTwoFactor( 'passkey_removed', ] as const) { await recordTwoFactorLifecycleEvent(sql, { - userId, + userId: lifecycleUserId, action, actorEmail: email, ip: '203.0.113.9', @@ -31828,11 +31865,12 @@ async function checkTwoFactor( const shapes = await sql<{ category: string; status: string }[]>` SELECT DISTINCT category, status FROM app.audit_logs WHERE org_id = ${ctx.orgId} AND resource_type = 'twoFactorAuth' - AND resource_id = ${userId} + AND resource_id = ${lifecycleUserId} `; - // Leave 2FA OFF: later lanes sign this account in with password alone. + // The dedicated user ends with 2FA off, which is also what the assertion + // below reads — no later lane touches this account either way. const enrolled = await sql<{ enabled: boolean | null }[]>` - SELECT "twoFactorEnabled" AS enabled FROM "user" WHERE "id" = ${userId} + SELECT "twoFactorEnabled" AS enabled FROM "user" WHERE "id" = ${lifecycleUserId} `; record( 'two-factor: successful lifecycle events audit (#1508 action names)',