Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions services/platform/backend/integration-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> =>
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<string[]> => {
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);
Expand Down Expand Up @@ -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
`;
Expand All @@ -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',
Expand All @@ -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)',
Expand Down
Loading