diff --git a/server/public/dashboard-settings.html b/server/public/dashboard-settings.html index c22567522b3..ddffe28ac36 100644 --- a/server/public/dashboard-settings.html +++ b/server/public/dashboard-settings.html @@ -676,14 +676,14 @@ @@ -989,11 +989,15 @@

Member directory

Linked emails

-

Link additional email addresses to consolidate duplicate accounts.

+

Link additional email addresses after verifying ownership. Existing accounts require support assistance.

+

Loading...

- + @@ -1046,12 +1050,63 @@

You're unsubscribed from all emails

// ========================================== // Linked Emails // ========================================== + var linkedEmailStatusKnown = false; + var linkedEmailReconciliationRequired = false; + var linkedEmailMutationPending = false; + var linkedEmailLoadVersion = 0; + var linkedEmailCredentialId = null; + var linkedEmailReconciliationMessage = 'Your email change needs reconciliation. Contact support before making another email change.'; + + function canMutateLinkedEmails() { + return !!linkedEmailCredentialId && linkedEmailStatusKnown && !linkedEmailReconciliationRequired && !linkedEmailMutationPending; + } + + function updateLinkedEmailControls() { + var disabled = !canMutateLinkedEmails(); + document.querySelectorAll('.make-primary-btn, #openLinkEmailBtn, #linkEmailBtn, #linkEmailInput').forEach(function(control) { + control.disabled = disabled; + }); + } + + function showLinkedEmailStatus(message) { + document.getElementById('linkedEmailStatusMessage').textContent = message ? message + ' ' : ''; + document.getElementById('linkedEmailStatus').style.display = message ? 'block' : 'none'; + } + + function applyLinkedEmailStatus(data, authoritative) { + if (data.reconciliation_required === true) { + linkedEmailReconciliationRequired = true; + linkedEmailStatusKnown = true; + showLinkedEmailStatus(data.message || linkedEmailReconciliationMessage); + } else if (data.status_unknown === true) { + linkedEmailStatusKnown = false; + showLinkedEmailStatus(data.message || 'Unable to confirm your email change. Reload to check its status or contact support.'); + } else if (authoritative && data.reconciliation_required === false) { + linkedEmailReconciliationRequired = false; + linkedEmailStatusKnown = true; + showLinkedEmailStatus(''); + } + updateLinkedEmailControls(); + } + async function loadLinkedEmails() { + var loadVersion = ++linkedEmailLoadVersion; + linkedEmailStatusKnown = false; + updateLinkedEmailControls(); try { var response = await fetch('/api/me/linked-emails', { credentials: 'include' }); - if (!response.ok) return; var data = await response.json(); + if (loadVersion !== linkedEmailLoadVersion) return; + if (!response.ok) { + applyLinkedEmailStatus(data, false); + if (!linkedEmailReconciliationRequired) { + showLinkedEmailStatus(data.message || data.error || 'Unable to check email change status. Reload this page or contact support.'); + } + return; + } + linkedEmailCredentialId = typeof data.credential_id === 'string' ? data.credential_id : null; + if (!linkedEmailCredentialId) throw new Error('Credential status unavailable'); var container = document.getElementById('linkedEmailsList'); var html = ''; @@ -1064,7 +1119,7 @@

You're unsubscribed from all emails

html += '
' + '' + accountEscapeHtml(data.aliases[i].email) + '' + 'Linked' + - '' + + '' + '
'; } @@ -1082,47 +1137,84 @@

You're unsubscribed from all emails

setPrimaryEmail(btn.dataset.email); }); }); + applyLinkedEmailStatus(data, true); + if (!linkedEmailStatusKnown) { + showLinkedEmailStatus('Unable to check email change status. Reload this page or contact support.'); + } } catch (error) { - console.error('Failed to load linked emails:', error); + if (loadVersion !== linkedEmailLoadVersion) return; + if (!linkedEmailReconciliationRequired) { + showLinkedEmailStatus('Unable to check email change status. Reload this page or contact support.'); + } + } + } + + function primaryEmailOperation(email) { + var key = 'member-primary-email:' + linkedEmailCredentialId; + var saved = window.sessionStorage.getItem(key); + var operation = saved ? JSON.parse(saved) : null; + var normalizedEmail = email.trim().toLowerCase(); + if (!operation || operation.email !== normalizedEmail) { + operation = { operation_id: window.crypto.randomUUID(), email: normalizedEmail }; + // Persist before sending: retries after a lost response or page reload + // must use the same caller operation ID, never start a second mutation. + window.sessionStorage.setItem(key, JSON.stringify(operation)); } + return { key: key, id: operation.operation_id }; } async function setPrimaryEmail(email) { + if (!canMutateLinkedEmails()) return; if (!confirm('Make ' + email + ' your primary email? You will need to use this email to sign in.')) return; - var btns = document.querySelectorAll('.make-primary-btn'); - btns.forEach(function(b) { b.disabled = true; }); + linkedEmailMutationPending = true; + ++linkedEmailLoadVersion; + updateLinkedEmailControls(); try { + var operation = primaryEmailOperation(email); var response = await fetch('/api/me/linked-emails/primary', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: email }), + body: JSON.stringify({ email: email, operation_id: operation.id }), credentials: 'include' }); var data = await response.json(); + ++linkedEmailLoadVersion; + applyLinkedEmailStatus(data, false); - if (!response.ok) { - accountShowToast(data.error || 'Failed to update primary email', true); + if ((response.ok || data.reconciliation_required === false) + && data.reconciliation_required !== true && data.status_unknown !== true) { + window.sessionStorage.removeItem(operation.key); + } + + if (!response.ok || linkedEmailReconciliationRequired || data.status_unknown === true) { + accountShowToast(data.message || (linkedEmailReconciliationRequired ? linkedEmailReconciliationMessage : data.error) || 'Failed to update primary email', true); return; } accountShowToast('Primary email updated to ' + email); - loadLinkedEmails(); + await loadLinkedEmails(); } catch (error) { - accountShowToast('Failed to update primary email', true); + ++linkedEmailLoadVersion; + linkedEmailStatusKnown = false; + var message = 'Unable to confirm your email change. Reload to check its status or contact support before retrying.'; + showLinkedEmailStatus(message); + accountShowToast(message, true); } finally { - btns.forEach(function(b) { b.disabled = false; }); + linkedEmailMutationPending = false; + updateLinkedEmailControls(); } } function openLinkEmailModal() { + if (!canMutateLinkedEmails()) return; document.getElementById('linkEmailInput').value = ''; document.getElementById('linkEmailWarning').style.display = 'none'; document.getElementById('linkEmailWarningMessage').textContent = ''; - document.getElementById('linkEmailBtn').disabled = false; - document.getElementById('linkEmailBtn').textContent = 'Send Verification'; + document.getElementById('linkEmailBtn').textContent = 'Send verification'; + updateLinkedEmailControls(); document.getElementById('linkEmailModal').classList.add('show'); } @@ -1131,11 +1223,14 @@

You're unsubscribed from all emails

} async function sendLinkVerification() { + if (!canMutateLinkedEmails()) return; var email = document.getElementById('linkEmailInput').value.trim(); if (!email) return; var btn = document.getElementById('linkEmailBtn'); - btn.disabled = true; + linkedEmailMutationPending = true; + ++linkedEmailLoadVersion; + updateLinkedEmailControls(); btn.textContent = 'Sending...'; try { @@ -1147,9 +1242,11 @@

You're unsubscribed from all emails

}); var data = await response.json(); + ++linkedEmailLoadVersion; + applyLinkedEmailStatus(data, false); - if (!response.ok) { - var message = data.message || data.error || 'Failed to send verification'; + if (!response.ok || linkedEmailReconciliationRequired) { + var message = data.message || (linkedEmailReconciliationRequired ? linkedEmailReconciliationMessage : data.error) || 'Failed to send verification'; accountShowToast(message, true); document.getElementById('linkEmailWarningMessage').textContent = message + ' '; document.getElementById('linkEmailWarning').style.display = 'block'; @@ -1158,12 +1255,17 @@

You're unsubscribed from all emails

closeLinkEmailModal(); accountShowToast('Verification email sent! Check your inbox.'); - loadLinkedEmails(); + await loadLinkedEmails(); } catch (error) { - accountShowToast('Failed to send verification', true); + ++linkedEmailLoadVersion; + linkedEmailStatusKnown = false; + var message = 'Unable to confirm your email change. Reload to check its status or contact support before retrying.'; + showLinkedEmailStatus(message); + accountShowToast(message, true); } finally { - btn.disabled = false; - btn.textContent = 'Send Verification'; + linkedEmailMutationPending = false; + updateLinkedEmailControls(); + btn.textContent = 'Send verification'; } } diff --git a/server/src/addie/admin-status-lookup.ts b/server/src/addie/admin-status-lookup.ts index daf2ed7711a..95852452898 100644 --- a/server/src/addie/admin-status-lookup.ts +++ b/server/src/addie/admin-status-lookup.ts @@ -15,6 +15,7 @@ */ import { createLogger } from '../logger.js'; +import type { AuthorizationSnapshot } from '../db/user-authorization-snapshot-db.js'; import { WorkingGroupDatabase } from '../db/working-group-db.js'; import { getWebAdminStatusCache } from './admin-status-cache.js'; import { @@ -77,7 +78,7 @@ export async function isWebUserAAOAdmin(workosUserId: string): Promise */ export async function resolveWebUserAAOAdminAccess( workosUserId: string, - email: string | null | undefined, + snapshot: AuthorizationSnapshot | null | undefined, ): Promise { - return decideAAOAdminAccess(await isWebUserAAOAdmin(workosUserId), email); + return decideAAOAdminAccess(await isWebUserAAOAdmin(workosUserId), snapshot); } diff --git a/server/src/auth/admin-access.ts b/server/src/auth/admin-access.ts index 8ffc9ba0ed4..0755f2431d7 100644 --- a/server/src/auth/admin-access.ts +++ b/server/src/auth/admin-access.ts @@ -1,3 +1,5 @@ +import type { AuthorizationSnapshot } from '../db/user-authorization-snapshot-db.js'; + /** * Shared platform-admin authority vocabulary. * @@ -17,7 +19,15 @@ export interface AAOAdminAccessDecision { mechanism: AAOAdminAccessMechanism | null; } -export function isBreakGlassAdminEmail(email: string | null | undefined): boolean { +/** + * Email-based authority comes only from this request's primary-DB snapshot. + * A pending intent can precede any epoch change, so epoch freshness alone is + * insufficient. Missing state and unverified credentials never grant access. + */ +export function isBreakGlassAdmin(snapshot: AuthorizationSnapshot | null | undefined): boolean { + if (!snapshot || snapshot.credential.emailMutationPending !== false + || snapshot.credential.emailVerified !== true) return false; + const email = snapshot.credential.email; if (!email) return false; const normalizedEmail = email.trim().toLowerCase(); if (!normalizedEmail) return false; @@ -30,9 +40,9 @@ export function isBreakGlassAdminEmail(email: string | null | undefined): boolea /** Classify a membership result without duplicating break-glass interpretation. */ export function decideAAOAdminAccess( isAdminByWorkingGroup: boolean, - email: string | null | undefined, + snapshot: AuthorizationSnapshot | null | undefined, ): AAOAdminAccessDecision { if (isAdminByWorkingGroup) return { isAdmin: true, mechanism: 'aao_admin_working_group' }; - if (isBreakGlassAdminEmail(email)) return { isAdmin: true, mechanism: 'break_glass_admin_email' }; + if (isBreakGlassAdmin(snapshot)) return { isAdmin: true, mechanism: 'break_glass_admin_email' }; return { isAdmin: false, mechanism: null }; } diff --git a/server/src/auth/organization-principal.ts b/server/src/auth/organization-principal.ts index dccf139df37..5f4ef47cb06 100644 --- a/server/src/auth/organization-principal.ts +++ b/server/src/auth/organization-principal.ts @@ -2,7 +2,7 @@ import type { WorkOSUser } from "../types.js"; export type OrgAuthorizationPrincipal = Pick< WorkOSUser, - "id" | "authWorkosUserId" + "id" | "authWorkosUserId" | "authorizationSnapshot" >; /** diff --git a/server/src/auth/workos-client.ts b/server/src/auth/workos-client.ts index fa155e88293..fb8755b581e 100644 --- a/server/src/auth/workos-client.ts +++ b/server/src/auth/workos-client.ts @@ -7,12 +7,14 @@ const OWNERLESS_PROMOTION_WORKOS_TIMEOUT_MS = 10_000; const PIPES_WORKOS_TIMEOUT_MS = 10_000; const AUTHORIZATION_OBSERVER_WORKOS_TIMEOUT_MS = 3_000; const AUTHORIZATION_ENFORCEMENT_WORKOS_TIMEOUT_MS = 5_000; +const EMAIL_MUTATION_WORKOS_TIMEOUT_MS = 10_000; let _workos: WorkOS | null = null; let _ownerlessPromotionWorkos: WorkOS | null = null; let _pipesWorkos: WorkOS | null = null; let _authorizationObserverWorkos: WorkOS | null = null; let _authorizationEnforcementWorkos: WorkOS | null = null; +let _emailMutationWorkos: WorkOS | null = null; let _clientId = ''; /** Returns the shared WorkOS client. Constructed on first call; WORKOS_API_KEY and WORKOS_CLIENT_ID must be set by then. */ @@ -97,6 +99,20 @@ export function getAuthorizationEnforcementWorkos(): WorkOS { return _authorizationEnforcementWorkos; } +/** One bounded attempt: an email mutation timeout requires reconciliation. */ +export function getEmailMutationWorkos(): WorkOS { + if (!_emailMutationWorkos) { + if (!process.env.WORKOS_API_KEY) throw new Error('WORKOS_API_KEY environment variable is required'); + if (!process.env.WORKOS_CLIENT_ID) throw new Error('WORKOS_CLIENT_ID environment variable is required'); + _emailMutationWorkos = new WorkOS(process.env.WORKOS_API_KEY, { + clientId: process.env.WORKOS_CLIENT_ID, + timeout: EMAIL_MUTATION_WORKOS_TIMEOUT_MS, + maxRetries: 0, + }); + } + return _emailMutationWorkos; +} + /** * Get the authorization URL to redirect users to WorkOS for authentication */ diff --git a/server/src/db/client.ts b/server/src/db/client.ts index cfc6dfb77f9..172688bccfa 100644 --- a/server/src/db/client.ts +++ b/server/src/db/client.ts @@ -147,8 +147,65 @@ export function withDatabaseDeadline( return queryDeadline.run({ deadlineMs, readOnly: options.readOnly ?? true }, work); } +function databaseDeadlineExceededError(): Error & { code: '57014' } { + return Object.assign(new Error('Database query deadline exceeded'), { code: '57014' as const }); +} + +/** + * Check out a pooled client without allowing pool saturation to outlive the + * caller's absolute deadline. A client delivered after the timer wins is + * released immediately so a timed-out request cannot leak a pool slot. + */ +async function checkoutClientBeforeDeadline(deadlineMs: number): Promise { + const remainingMs = deadlineMs - Date.now(); + if (remainingMs <= 0) throw databaseDeadlineExceededError(); + + const checkout = getPool().connect(); + let timeout: ReturnType | undefined; + let timedOut = false; + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + timedOut = true; + reject(databaseDeadlineExceededError()); + }, remainingMs); + }); + + try { + return await Promise.race([checkout, deadline]); + } catch (error) { + if (timedOut) { + void checkout.then( + (lateClient) => lateClient.release(), + () => undefined, + ); + } + throw error; + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + +async function getClientBeforeDeadline( + deadlineMs: number, + retryTransientCheckout: boolean, +): Promise { + const attempts = retryTransientCheckout ? 2 : 1; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await checkoutClientBeforeDeadline(deadlineMs); + } catch (error) { + if (attempt === attempts - 1 || !isTransientConnectionError(error) || Date.now() >= deadlineMs) { + throw error; + } + console.warn('Transient DB connection error, retrying client checkout:', (error as Error).message); + } + } + throw databaseDeadlineExceededError(); +} + /** - * Execute one query with server-enforced statement and lock deadlines. + * Execute one query with deadline-bounded pool checkout plus server-enforced + * statement and lock deadlines. * * Use this for public read paths whose inputs can select unusually large * registry fan-outs. The transaction-local settings ensure PostgreSQL stops @@ -159,18 +216,22 @@ export async function queryWithTimeout( text: string, params: any[] | undefined, timeoutMs: number, + options: { retryTransientCheckout?: boolean } = {}, ): Promise> { const inheritedDeadline = queryDeadline.getStore(); const deadlineMs = Math.min( Date.now() + timeoutMs, inheritedDeadline?.deadlineMs ?? Number.POSITIVE_INFINITY, ); - const client = await getClient(); + const client = await getClientBeforeDeadline( + deadlineMs, + options.retryTransientCheckout ?? true, + ); let transactionStarted = false; try { const effectiveTimeoutMs = deadlineMs - Date.now(); if (effectiveTimeoutMs <= 0) { - throw Object.assign(new Error('Database query deadline exceeded'), { code: '57014' }); + throw databaseDeadlineExceededError(); } await client.query(inheritedDeadline?.readOnly === false ? 'BEGIN' : 'BEGIN READ ONLY'); transactionStarted = true; diff --git a/server/src/db/migrations/592_email_mutations.sql b/server/src/db/migrations/592_email_mutations.sql new file mode 100644 index 00000000000..e726bbaaa21 --- /dev/null +++ b/server/src/db/migrations/592_email_mutations.sql @@ -0,0 +1,316 @@ +-- Allocation re-audited 2026-09-13T14:47:22.073294+00:00 against main dd6b04964a000f82ebd687e817e8e748fe73eb5c. +-- All 33 open PRs / 2891 files were fully paginated; heads were rechecked. +-- 592 remains owned only by #7463 and has not been released on main. +-- 588-591 belong to other slices; 591 exclusively PR1c (#7457). +-- No dependency on those migrations. Exact ledger heads/checksums are in the PR body. +-- Retain journal evidence without a users FK, including after account deletion. +ALTER TABLE users ADD COLUMN email_mutation_version BIGINT NOT NULL DEFAULT 0 + CHECK (email_mutation_version >= 0); + +CREATE TABLE email_mutations ( + id UUID PRIMARY KEY, + workos_user_id TEXT NOT NULL, + actor_user_id TEXT NOT NULL, + payload_hash TEXT NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$'), + old_email TEXT NOT NULL, + old_email_verified BOOLEAN NOT NULL, + new_email TEXT NOT NULL, + expected_email_version BIGINT NOT NULL CHECK (expected_email_version >= 0), + applied_email_version BIGINT, + epoch_after BIGINT CHECK (epoch_after > 0), + state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'compensated', 'reconciliation_required')), + failure_code TEXT CHECK (failure_code ~ '^[a-z][a-z0-9_]{0,79}$'), + result_status INTEGER NOT NULL, + result_body JSONB NOT NULL CHECK ( + jsonb_typeof(result_body) = 'object' + AND result_body @> jsonb_build_object('operation_id', id::text) + ), + reconciliation_attempts JSONB NOT NULL DEFAULT '[]'::jsonb + CHECK (jsonb_typeof(reconciliation_attempts) = 'array'), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (workos_user_id, id), + UNIQUE (workos_user_id, applied_email_version), + CHECK ( + (state = 'pending' AND failure_code IS NULL) + OR (state IN ('compensated', 'reconciliation_required') AND failure_code IS NOT NULL) + OR (state = 'succeeded' AND failure_code IS NULL) + ), + CHECK ( + (state = 'pending' + AND result_status = 409 + AND result_body @> '{"reconciliation_required":true}'::jsonb + AND applied_email_version IS NULL AND epoch_after IS NULL) + OR + (state = 'reconciliation_required' + AND result_status = 409 + AND result_body @> '{"reconciliation_required":true}'::jsonb + AND applied_email_version IS NULL AND epoch_after IS NOT NULL) + OR + (state = 'succeeded' AND result_status = 200 + AND result_body @> jsonb_build_object('primary_email', new_email) + AND result_body->>'reconciliation_required' IS DISTINCT FROM 'true' + AND applied_email_version IS NOT NULL + AND applied_email_version = expected_email_version + 1 AND epoch_after IS NOT NULL) + OR + (state = 'compensated' AND result_status BETWEEN 400 AND 599 + AND result_body ? 'error' AND jsonb_typeof(result_body->'error') = 'string' + AND result_body->>'reconciliation_required' IS DISTINCT FROM 'true' + AND applied_email_version IS NOT NULL + AND applied_email_version = expected_email_version + 1 AND epoch_after IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX email_mutations_one_unresolved_credential + ON email_mutations (workos_user_id) + WHERE state IN ('pending', 'reconciliation_required'); +CREATE INDEX email_mutations_credential_history + ON email_mutations (workos_user_id, expected_email_version DESC); + +-- Every writer uses the saga's credential lock. A BEFORE row trigger already +-- holds tuple locks: waiting here would invert the saga's advisory -> row order. +CREATE FUNCTION lock_email_writer(credential_id TEXT) RETURNS VOID LANGUAGE plpgsql AS $$ +BEGIN + IF credential_id IS NOT NULL + AND NOT pg_try_advisory_xact_lock(hashtextextended(credential_id, 6827)) THEN + RAISE EXCEPTION 'Credential email mutation is busy' USING ERRCODE = '55P03'; + END IF; +END; +$$; + +CREATE FUNCTION protect_email_mutation_intent() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + PERFORM lock_email_writer(NEW.workos_user_id); + IF TG_OP = 'INSERT' THEN + IF NEW.state <> 'pending' OR NOT EXISTS ( + SELECT 1 FROM users WHERE workos_user_id = NEW.workos_user_id + AND email = NEW.old_email AND email_verified = NEW.old_email_verified + AND email_mutation_version = NEW.expected_email_version + ) THEN + RAISE EXCEPTION 'Email mutation must start pending from the current credential version' USING ERRCODE = '23514'; + END IF; + ELSE + IF ROW(NEW.id, NEW.workos_user_id, NEW.actor_user_id, NEW.payload_hash, + NEW.old_email, NEW.old_email_verified, NEW.new_email, NEW.expected_email_version) + IS DISTINCT FROM + ROW(OLD.id, OLD.workos_user_id, OLD.actor_user_id, OLD.payload_hash, + OLD.old_email, OLD.old_email_verified, OLD.new_email, OLD.expected_email_version) THEN + RAISE EXCEPTION 'Email mutation intent is immutable' USING ERRCODE = '23514'; + END IF; + IF OLD.state IN ('succeeded', 'compensated') AND NEW IS DISTINCT FROM OLD THEN + RAISE EXCEPTION 'Email mutation terminal result is immutable' USING ERRCODE = '23514'; + END IF; + IF NEW.state IN ('succeeded', 'compensated') + AND NEW.id::text IS DISTINCT FROM NULLIF(current_setting('adcp.email_mutation_id', true), '') THEN + RAISE EXCEPTION 'Email mutation terminal result requires its operation transaction' USING ERRCODE = '23514'; + END IF; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER protect_email_mutation_intent + BEFORE INSERT OR UPDATE ON email_mutations + FOR EACH ROW EXECUTE FUNCTION protect_email_mutation_intent(); + +CREATE FUNCTION fence_credential_email_writer() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE + canonical users%ROWTYPE; + operation email_mutations%ROWTYPE; + operation_setting TEXT := NULLIF(current_setting('adcp.email_mutation_id', true), ''); + protected BOOLEAN; + previous_epoch BIGINT; + updated_epoch BIGINT; + affected_rows BIGINT; +BEGIN + PERFORM lock_email_writer(NEW.workos_user_id); + IF TG_OP = 'UPDATE' THEN + canonical := OLD; + IF NEW.workos_user_id IS DISTINCT FROM OLD.workos_user_id THEN + PERFORM lock_email_writer(OLD.workos_user_id); + IF EXISTS (SELECT 1 FROM email_mutations WHERE workos_user_id IN (OLD.workos_user_id, NEW.workos_user_id)) THEN + RAISE EXCEPTION 'Journaled credential identity cannot be reassigned' USING ERRCODE = '23514'; + END IF; + END IF; + ELSE + -- Also fence INSERT ... ON CONFLICT before it can apply a stale EXCLUDED + -- snapshot. This covers OAuth callbacks, webhooks, MCP and periodic resync. + SELECT * INTO canonical FROM users WHERE workos_user_id = NEW.workos_user_id; + END IF; + SELECT EXISTS (SELECT 1 FROM email_mutations WHERE workos_user_id = NEW.workos_user_id) INTO protected; + IF operation_setting IS NOT NULL AND TG_OP = 'UPDATE' THEN + SELECT * INTO operation FROM email_mutations + WHERE id::text = operation_setting AND workos_user_id = NEW.workos_user_id + AND state IN ('pending', 'reconciliation_required'); + IF operation.id IS NULL + OR canonical.email_mutation_version <> operation.expected_email_version + OR NEW.email_mutation_version <> canonical.email_mutation_version + OR NOT COALESCE(((NEW.email = operation.new_email AND NEW.email_verified IS TRUE) + OR (NEW.email = operation.old_email AND NEW.email_verified = operation.old_email_verified)), FALSE) THEN + RAISE EXCEPTION 'Email mutation operation or version does not authorize this write' USING ERRCODE = '23514'; + END IF; + -- Advance even when compensation rewrites the same pair after a rollback. + NEW.email_mutation_version := canonical.email_mutation_version + 1; + RETURN NEW; + END IF; + IF TG_OP = 'UPDATE' AND NEW.email_mutation_version <> canonical.email_mutation_version THEN + RAISE EXCEPTION 'Credential email version is managed by the writer fence' USING ERRCODE = '23514'; + END IF; + IF protected AND (canonical.workos_user_id IS NULL + OR NEW.email IS DISTINCT FROM canonical.email + OR NEW.email_verified IS DISTINCT FROM canonical.email_verified) THEN + -- A provider's updated_at cannot order a delayed mutation/compensation. + -- The committed canonical pair stays authoritative until a new journaled + -- operation is resolved. This remains active after terminal outcomes. + RAISE EXCEPTION 'Credential email requires mutation reconciliation' USING ERRCODE = '23514'; + END IF; + IF TG_OP = 'UPDATE' AND + (NEW.email IS DISTINCT FROM OLD.email OR NEW.email_verified IS DISTINCT FROM OLD.email_verified) THEN + NEW.email_mutation_version := OLD.email_mutation_version + 1; + SELECT epoch INTO previous_epoch FROM authorization_epochs + WHERE workos_user_id = NEW.workos_user_id FOR UPDATE; + INSERT INTO authorization_epochs (workos_user_id, epoch) VALUES (NEW.workos_user_id, 1) + ON CONFLICT (workos_user_id) DO UPDATE + SET epoch = authorization_epochs.epoch + 1, updated_at = NOW() + RETURNING epoch INTO updated_epoch; + GET DIAGNOSTICS affected_rows = ROW_COUNT; + IF affected_rows <> 1 OR updated_epoch IS DISTINCT FROM COALESCE(previous_epoch, 0) + 1 + OR NOT EXISTS (SELECT 1 FROM authorization_epochs + WHERE workos_user_id = NEW.workos_user_id AND epoch = updated_epoch) THEN + RAISE EXCEPTION 'Credential email write lacks its authorization epoch' USING ERRCODE = '23514'; + END IF; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER fence_credential_email_writer + BEFORE INSERT OR UPDATE OF email, email_verified, email_mutation_version, workos_user_id ON users + FOR EACH ROW EXECUTE FUNCTION fence_credential_email_writer(); + +-- An authorized UPDATE cannot commit while its journal is still pending, or +-- after a trigger silently skipped the journal/epoch write. Match the persisted +-- monotonic version, never caller UUID order or potentially tied timestamps. +CREATE FUNCTION verify_email_mutation_commit() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE + operation email_mutations%ROWTYPE; +BEGIN + IF NEW.email_mutation_version = OLD.email_mutation_version THEN RETURN NULL; END IF; + IF NOT EXISTS (SELECT 1 FROM email_mutations WHERE workos_user_id = NEW.workos_user_id) THEN RETURN NULL; END IF; + SELECT * INTO operation FROM email_mutations + WHERE workos_user_id = NEW.workos_user_id AND applied_email_version = NEW.email_mutation_version + AND state IN ('succeeded', 'compensated'); + IF operation.id IS NULL + OR (operation.state = 'succeeded' AND (NEW.email <> operation.new_email OR NEW.email_verified IS DISTINCT FROM TRUE)) + OR (operation.state = 'compensated' AND (NEW.email <> operation.old_email OR NEW.email_verified IS DISTINCT FROM operation.old_email_verified)) + OR NOT EXISTS (SELECT 1 FROM authorization_epochs + WHERE workos_user_id = NEW.workos_user_id AND epoch = operation.epoch_after) THEN + RAISE EXCEPTION 'Email mutation local commit lacks its terminal journal and epoch' USING ERRCODE = '23514'; + END IF; + RETURN NULL; +END; +$$; +CREATE CONSTRAINT TRIGGER verify_email_mutation_commit + AFTER UPDATE ON users DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION verify_email_mutation_commit(); + +CREATE FUNCTION verify_email_mutation_terminal() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.state = 'reconciliation_required' AND NOT EXISTS ( + SELECT 1 FROM users u JOIN authorization_epochs epoch USING (workos_user_id) + WHERE u.workos_user_id = NEW.workos_user_id + AND u.email_mutation_version = NEW.expected_email_version + AND u.email = NEW.old_email AND u.email_verified = NEW.old_email_verified + AND epoch.epoch >= NEW.epoch_after + ) THEN + RAISE EXCEPTION 'Email reconciliation lacks matching local evidence and revocation' USING ERRCODE = '23514'; + END IF; + IF NEW.state IN ('succeeded', 'compensated') AND NOT EXISTS ( + SELECT 1 FROM users u JOIN authorization_epochs epoch USING (workos_user_id) + WHERE u.workos_user_id = NEW.workos_user_id + AND u.email_mutation_version = NEW.applied_email_version + AND epoch.epoch = NEW.epoch_after + AND ((NEW.state = 'succeeded' AND u.email = NEW.new_email AND u.email_verified IS TRUE) + OR (NEW.state = 'compensated' AND u.email = NEW.old_email AND u.email_verified = NEW.old_email_verified)) + ) THEN + RAISE EXCEPTION 'Email mutation terminal result lacks matching local evidence' USING ERRCODE = '23514'; + END IF; + RETURN NULL; +END; +$$; +CREATE CONSTRAINT TRIGGER verify_email_mutation_terminal + AFTER INSERT OR UPDATE ON email_mutations DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION verify_email_mutation_terminal(); + +CREATE FUNCTION fence_email_denormalization_writer() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE + canonical_email TEXT; +BEGIN + PERFORM lock_email_writer(NEW.workos_user_id); + IF TG_OP = 'UPDATE' AND OLD.workos_user_id IS DISTINCT FROM NEW.workos_user_id THEN + PERFORM lock_email_writer(OLD.workos_user_id); + END IF; + IF EXISTS (SELECT 1 FROM email_mutations WHERE workos_user_id = NEW.workos_user_id) THEN + SELECT email INTO canonical_email FROM users WHERE workos_user_id = NEW.workos_user_id; + IF canonical_email IS NULL THEN + RAISE EXCEPTION 'Credential email denormalization requires reconciliation' USING ERRCODE = '23514'; + END IF; + -- Keep role/status revocations from mixed provider snapshots effective; + -- only their stale denormalized email is replaced by the canonical value. + NEW.email := canonical_email; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER fence_membership_email_writer + BEFORE INSERT OR UPDATE OF email, workos_user_id ON organization_memberships + FOR EACH ROW EXECUTE FUNCTION fence_email_denormalization_writer(); +CREATE TRIGGER fence_person_email_writer + BEFORE INSERT OR UPDATE OF email, workos_user_id ON person_relationships + FOR EACH ROW EXECUTE FUNCTION fence_email_denormalization_writer(); + +CREATE FUNCTION fence_email_alias_token_writer() RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE + old_owner TEXT; + new_owner TEXT; + owner_id TEXT; + operation email_mutations%ROWTYPE; + operation_setting TEXT := NULLIF(current_setting('adcp.email_mutation_id', true), ''); +BEGIN + IF TG_TABLE_NAME = 'user_email_aliases' THEN + IF TG_OP <> 'INSERT' THEN old_owner := OLD.workos_user_id; END IF; + IF TG_OP <> 'DELETE' THEN new_owner := NEW.workos_user_id; END IF; + ELSE + IF TG_OP <> 'INSERT' THEN old_owner := OLD.primary_workos_user_id; END IF; + IF TG_OP <> 'DELETE' THEN new_owner := NEW.primary_workos_user_id; END IF; + END IF; + FOR owner_id IN SELECT DISTINCT owner FROM unnest(ARRAY[old_owner, new_owner]) owner WHERE owner IS NOT NULL ORDER BY owner LOOP + PERFORM lock_email_writer(owner_id); + -- A provider-authoritative users DELETE must finish its FK cascades so + -- memberships and sessions can be revoked. The deleted parent is absent + -- inside that transaction; a direct alias/token DELETE still sees it and + -- remains fenced. The journal has no users FK and retains the evidence. + IF TG_OP = 'DELETE' AND NOT EXISTS (SELECT 1 FROM users WHERE workos_user_id = owner_id) THEN + CONTINUE; + END IF; + SELECT * INTO operation FROM email_mutations WHERE workos_user_id = owner_id + AND state IN ('pending', 'reconciliation_required'); + IF operation.id IS NOT NULL THEN + IF TG_TABLE_NAME <> 'user_email_aliases' + OR operation.id::text IS DISTINCT FROM operation_setting + OR (old_owner IS NOT NULL AND old_owner <> owner_id) + OR (new_owner IS NOT NULL AND new_owner <> owner_id) + OR (TG_OP <> 'INSERT' AND to_jsonb(OLD)->>'email' NOT IN (operation.old_email, operation.new_email)) + OR (TG_OP <> 'DELETE' AND to_jsonb(NEW)->>'email' NOT IN (operation.old_email, operation.new_email)) THEN + RAISE EXCEPTION 'Credential alias or token mutation requires reconciliation' USING ERRCODE = '23514'; + END IF; + END IF; + END LOOP; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER fence_email_alias_writer + BEFORE INSERT OR UPDATE OR DELETE ON user_email_aliases + FOR EACH ROW EXECUTE FUNCTION fence_email_alias_token_writer(); +CREATE TRIGGER fence_email_link_token_writer + BEFORE INSERT OR UPDATE OR DELETE ON email_link_tokens + FOR EACH ROW EXECUTE FUNCTION fence_email_alias_token_writer(); diff --git a/server/src/db/user-authorization-snapshot-db.ts b/server/src/db/user-authorization-snapshot-db.ts new file mode 100644 index 00000000000..2d6557c452a --- /dev/null +++ b/server/src/db/user-authorization-snapshot-db.ts @@ -0,0 +1,199 @@ +import { isTransientConnectionError, queryWithTimeout } from './client.js'; + +const SNAPSHOT_TIMEOUT_MS = 2_000; + +/** The exact credential and its authority, observed in one primary-database statement. */ +export interface AuthorizationSnapshot { + readonly authenticatedUserId: string; + readonly canonicalUserId: string; + readonly identityId: string | null; + readonly selectedOrganizationId: string | null; + /** PostgreSQL bigint text: converting to a number would lose revocations above 2^53. */ + readonly authorizationEpoch: string; + readonly credential: Readonly<{ + email: string | null; + emailVerified: boolean; + /** Durable intent must fence requests even before an epoch is advanced. */ + emailMutationPending: boolean; + firstName: string | null; + lastName: string | null; + }>; + readonly credentialGrant: Readonly<{ + id: string; + organizationId: string; + role: 'owner' | 'admin' | 'member'; + effectiveFrom: string; + effectiveUntil: string | null; + }> | null; +} + +/** Distinct from a missing credential or denied organization access. */ +export class AuthorizationSnapshotUnavailableError extends Error { + constructor(message = 'Authorization snapshot unavailable') { + super(message); + this.name = 'AuthorizationSnapshotUnavailableError'; + } +} + +interface SnapshotRow { + in_recovery: boolean; + authenticated_user_id: string | null; + canonical_user_id: string | null; + identity_id: string | null; + authorization_epoch: string; + email: string | null; + email_verified: boolean; + email_mutation_pending: boolean; + first_name: string | null; + last_name: string | null; + grant_id: string | null; + grant_organization_id: string | null; + grant_role: 'owner' | 'admin' | 'member' | null; + grant_effective_from: string | null; + grant_effective_until: string | null; +} + +/** A retry may replace a broken connection, never renew the statement budget. */ +async function querySnapshot(statement: string, parameters: [string, string | null]) { + const deadlineMs = Date.now() + SNAPSHOT_TIMEOUT_MS; + for (let attempt = 0; attempt < 2; attempt++) { + const remainingMs = deadlineMs - Date.now(); + if (remainingMs <= 0) throw new AuthorizationSnapshotUnavailableError(); + try { + const result = await queryWithTimeout(statement, parameters, remainingMs, { + // querySnapshot owns the one retry so checkout and statement failures + // share this request's single absolute authorization budget. + retryTransientCheckout: false, + }); + if (Date.now() >= deadlineMs) throw new AuthorizationSnapshotUnavailableError(); + return result; + } catch (error) { + if (attempt === 1 || !isTransientConnectionError(error) || Date.now() >= deadlineMs) throw error; + } + } + throw new AuthorizationSnapshotUnavailableError(); +} + +/** + * Never hydrate identity and epoch separately: a binding change can commit + * between the reads and stamp old attribution with the new epoch. One SELECT + * gives every field the same PostgreSQL MVCC snapshot. The bounded query wrapper + * uses the primary pool with transaction-local statement and lock timeouts; + * a recovering replica fails closed in this statement. + * + * Organization selection must be explicit. Neither linked credentials nor the + * organization_memberships cache supplies authority on a cold start/cache miss. + */ +export async function loadAuthorizationSnapshot( + authenticatedUserId: string, + selectedOrganizationId: string | null, +): Promise { + const organizationId = selectedOrganizationId || null; + try { + const result = await querySnapshot( + `SELECT pg_catalog.pg_is_in_recovery() AS in_recovery, + credential.workos_user_id AS authenticated_user_id, + primary_binding.workos_user_id AS canonical_user_id, + binding.identity_id, + COALESCE(epoch.epoch, 0)::text AS authorization_epoch, + credential.email, credential.email_verified, credential.first_name, credential.last_name, + EXISTS (SELECT 1 FROM email_mutations mutation + WHERE mutation.workos_user_id = credential.workos_user_id + AND mutation.state IN ('pending', 'reconciliation_required')) AS email_mutation_pending, + credential_grant.id AS grant_id, + credential_grant.workos_organization_id AS grant_organization_id, + credential_grant.role AS grant_role, + to_char(credential_grant.effective_from AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS grant_effective_from, + to_char(credential_grant.effective_until AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS grant_effective_until + FROM (VALUES (1)) AS anchor(value) + LEFT JOIN users credential ON credential.workos_user_id = $1 + LEFT JOIN identity_workos_users binding + ON binding.workos_user_id = credential.workos_user_id + LEFT JOIN identity_workos_users primary_binding + ON primary_binding.identity_id = binding.identity_id AND primary_binding.is_primary + LEFT JOIN authorization_epochs epoch + ON epoch.workos_user_id = credential.workos_user_id + LEFT JOIN organization_credential_grants credential_grant + ON credential_grant.workos_user_id = credential.workos_user_id + AND credential_grant.workos_organization_id = $2 + AND credential_grant.revoked_at IS NULL + AND credential_grant.effective_from <= statement_timestamp() + AND (credential_grant.effective_until IS NULL + OR credential_grant.effective_until > statement_timestamp())`, + [authenticatedUserId, organizationId], + ); + const row = result.rows[0]; + if (!row || row.in_recovery !== false || typeof row.email_mutation_pending !== 'boolean') { + throw new AuthorizationSnapshotUnavailableError(); + } + if (!row.authenticated_user_id) return null; + // Migration 460 backfills bindings and creates them on every user insert. + // Missing attribution is an integrity failure, not a singleton fallback. + if (!row.identity_id || !row.canonical_user_id) { + throw new AuthorizationSnapshotUnavailableError(); + } + + const credentialGrant = row.grant_id + ? Object.freeze({ + id: row.grant_id, + organizationId: row.grant_organization_id!, + role: row.grant_role!, + effectiveFrom: row.grant_effective_from!, + effectiveUntil: row.grant_effective_until, + }) + : null; + return Object.freeze({ + authenticatedUserId: row.authenticated_user_id, + canonicalUserId: row.canonical_user_id, + identityId: row.identity_id, + selectedOrganizationId: organizationId, + authorizationEpoch: row.authorization_epoch, + credential: Object.freeze({ + email: row.email, + emailVerified: row.email_verified, + emailMutationPending: row.email_mutation_pending, + firstName: row.first_name, + lastName: row.last_name, + }), + credentialGrant, + }); + } catch (error) { + if (error instanceof AuthorizationSnapshotUnavailableError) throw error; + // Callers must distinguish unavailable from denied without exposing raw DB + // errors or reusing stale identity, organization, or grant cache entries. + throw new AuthorizationSnapshotUnavailableError(); + } +} + +export function sameAuthorizationIdentity( + previous: AuthorizationSnapshot, + current: AuthorizationSnapshot, +): boolean { + return previous.authenticatedUserId === current.authenticatedUserId + && previous.canonicalUserId === current.canonicalUserId + && previous.identityId === current.identityId + && previous.authorizationEpoch === current.authorizationEpoch + && previous.credential.email === current.credential.email + && previous.credential.emailVerified === current.credential.emailVerified + && typeof previous.credential.emailMutationPending === 'boolean' + && previous.credential.emailMutationPending === current.credential.emailMutationPending; +} + +/** Compare equality, not epoch ordering: deletions and replay can move backwards. */ +export function sameAuthorizationSnapshot( + previous: AuthorizationSnapshot, + current: AuthorizationSnapshot, +): boolean { + if (!sameAuthorizationIdentity(previous, current) + || previous.selectedOrganizationId !== current.selectedOrganizationId) return false; + const before = previous.credentialGrant; + const after = current.credentialGrant; + if (!before || !after) return before === after; + return before.id === after.id + && before.organizationId === after.organizationId + && before.role === after.role + && before.effectiveFrom === after.effectiveFrom + && before.effectiveUntil === after.effectiveUntil; +} diff --git a/server/src/http.ts b/server/src/http.ts index 773a82e1759..aac93d69a1b 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -107,7 +107,7 @@ import * as relationshipDb from "./db/relationship-db.js"; import * as personEvents from "./db/person-events-db.js"; import { isWebUserAAOAdmin } from "./addie/mcp/admin-tools.js"; import { resolveWebUserAAOAdminAccess } from "./addie/admin-status-lookup.js"; -import { isBreakGlassAdminEmail } from "./auth/admin-access.js"; +import { isBreakGlassAdmin } from "./auth/admin-access.js"; import { createSlackRouter } from "./routes/slack.js"; import { createWebhooksRouter } from "./routes/webhooks.js"; import { createWorkOSWebhooksRouter } from "./routes/workos-webhooks.js"; @@ -1079,15 +1079,15 @@ async function upsertInvoiceCache( * Build app config object for injection into HTML pages. * This allows nav.js to read config synchronously instead of making an async fetch. */ -function buildAppConfig(user?: { id?: string; email: string; firstName?: string | null; lastName?: string | null; isMember?: boolean; isAdmin?: boolean } | null) { +function buildAppConfig(user?: { authorizationSnapshot?: import("./db/user-authorization-snapshot-db.js").AuthorizationSnapshot; id?: string; email: string; firstName?: string | null; lastName?: string | null; isMember?: boolean; isAdmin?: boolean } | null) { // Trust a pre-resolved isAdmin (set by enrichUserWithAdmin / dev-user flag). - // Fall back to ADMIN_EMAILS for callers that haven't enriched yet. + // The fallback requires authoritative credential and mutation state. let isAdmin = false; if (user) { if (typeof user.isAdmin === 'boolean') { isAdmin = user.isAdmin; } else { - isAdmin = isBreakGlassAdminEmail(user.email); + isAdmin = isBreakGlassAdmin(user.authorizationSnapshot); } } @@ -8635,7 +8635,7 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}< // Match requireAdmin: working-group authority with an explicit, // environment-managed break-glass fallback. - const isAdmin = (await resolveWebUserAAOAdminAccess(user.id, user.email)).isAdmin; + const isAdmin = (await resolveWebUserAAOAdminAccess(user.id, user.authorizationSnapshot)).isAdmin; // Check Slack sync status, seat type, and read DB names (user may have // set a display name that differs from the WorkOS session values) let isLinkedToSlack = false; diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index b1e3c756f70..86ef6573852 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -13,8 +13,7 @@ import { bansDb } from '../db/bans-db.js'; import { isWorkOSApiKeyFormat } from './api-key-format.js'; import { verifyWorkOSJWT, looksLikeJWT } from '../auth/workos-jwt.js'; import { storeRefreshedSession, getRefreshedSession, cleanExpiredRefreshes } from '../db/session-refresh-db.js'; -import { getPool } from '../db/client.js'; -import { getAuthorizationFingerprint } from '../db/authorization-epoch-db.js'; +import { loadAuthorizationSnapshot, AuthorizationSnapshotUnavailableError } from '../db/user-authorization-snapshot-db.js'; import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js'; import { constantTimeEqual } from '../utils/constant-time-equal.js'; import { resolveEffectiveMembership } from '../db/org-filters.js'; @@ -35,8 +34,8 @@ interface CachedSession { accessToken: string; expiresAt: number; newSealedSession?: string; // Set if session was refreshed - /** Persisted authorization epoch observed when this entry was stored (#6827). */ - authorizationFingerprint?: string; + /** Explicit organization from the authenticated provider session, never inferred. */ + orgId?: string; } const sessionCache = new Map(); @@ -183,6 +182,14 @@ function isTransientAuthError(err: unknown): boolean { if (!(err instanceof Error)) return false; const name = err.name; const code = (err as { code?: unknown }).code; + const status = (err as { status?: unknown; statusCode?: unknown }).status + ?? (err as { statusCode?: unknown }).statusCode; + if (typeof status === 'number' && (status === 408 || status === 429 || (status >= 500 && status < 600))) return true; + // jose does not retain the HTTP status when the remote JWKS endpoint fails. + // This exact error denotes key-service failure, not token signature failure. + if (name === 'JOSEError' && code === 'ERR_JOSE_GENERIC' + && (err.message === 'Expected 200 OK from the JSON Web Key Set HTTP response' + || err.message === 'Failed to parse the JSON Web Key Set HTTP response as JSON')) return true; if (name === 'JWKSTimeout' || name === 'TimeoutError' || name === 'AbortError') return true; if (typeof code === 'string') { if (code === 'ERR_JWKS_TIMEOUT') return true; @@ -313,19 +320,15 @@ export interface ValidatedBearerJWT { /** * Short-lived positive cache for successfully-validated bearer JWTs. - * Keyed on SHA-256 of the raw token; value includes the synthesized user + * Keyed on SHA-256 of the raw token; value includes verified provider claims * and an expiry that is the minimum of (token exp, now + BEARER_JWT_CACHE_TTL_MS). * - * Without this, every REST request with a Bearer JWT incurs a DB round-trip - * for the local-user lookup — the cookie path has an equivalent cache - * (`sessionCache`) and its absence here creates a DoS amplifier. + * Only signature-verified provider claims live here. Request authority is + * hydrated afresh from the primary database; no canonical user or grant is cached. */ interface CachedBearerJWT { - user: WorkOSUser; - orgId?: string; + verified: Awaited>; expiresAt: number; - /** Persisted authorization epoch observed when this entry was stored (#6827). */ - authorizationFingerprint?: string; } const bearerJwtCache = new Map(); const BEARER_JWT_CACHE_TTL_MS = 60 * 1000; @@ -362,66 +365,38 @@ export async function validateWorkOSBearerJWT(req: Request): Promise>; if (cached && cached.expiresAt > now) { - if (await isAuthorizationFingerprintCurrent(cached.user, cached.authorizationFingerprint)) { - return { user: cached.user, rawToken: token, orgId: cached.orgId }; + verified = cached.verified; + } else { + try { + verified = await verifyWorkOSJWT(token); + } catch (err) { + if (isTransientAuthError(err)) throw err; + logger.debug({ err }, 'Bearer JWT verification failed'); + return null; } - // Identity bindings changed since this entry was stored — re-verify. - bearerJwtCache.delete(cacheKey); - } - - let verified: Awaited>; - try { - verified = await verifyWorkOSJWT(token); - } catch (err) { - logger.debug({ err }, 'Bearer JWT verification failed'); - return null; } - if (verified.isM2M || !verified.sub) return null; - // Confirm the subject corresponds to a real local user. This catches - // tokens from WorkOS accounts that have been deleted or never synced, - // and gives us names for the synthesized WorkOSUser. - const pool = getPool(); - const localUser = await pool.query<{ - first_name: string | null; - last_name: string | null; - email: string | null; - }>( - `SELECT first_name, last_name, email FROM users WHERE workos_user_id = $1`, - [verified.sub], - ); - if (localUser.rowCount === 0) { - logger.warn({ sub: verified.sub }, 'Bearer JWT verified but user not found in local DB'); - return null; - } - - const row = localUser.rows[0]; - const email = verified.email ?? row.email ?? ''; - const user: WorkOSUser = { + // Only provider authentication is cached. Identity, grants and the epoch + // always come from the same fresh primary-database snapshot, even on hits. + const user = await hydrateAuthenticatedUser({ id: verified.sub, - email, - firstName: row.first_name?.trim() || undefined, - lastName: row.last_name?.trim() || undefined, + email: verified.email ?? '', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - }; + }, selectedOrganizationForAuthentication(req, verified.orgId)); const tokenExpMs = verified.expiresAt ? verified.expiresAt * 1000 : Infinity; const cacheUntil = Math.min(now + BEARER_JWT_CACHE_TTL_MS, tokenExpMs); - if (cacheUntil > now) { + if ((!cached || cached.expiresAt <= now) && cacheUntil > now) { if (bearerJwtCache.size >= BEARER_JWT_CACHE_MAX_SIZE) { const oldest = bearerJwtCache.keys().next().value; if (oldest) bearerJwtCache.delete(oldest); } - bearerJwtCache.set(cacheKey, { - user, - orgId: verified.orgId, - expiresAt: cacheUntil, - authorizationFingerprint: await authorizationFingerprintFor(user), - }); + bearerJwtCache.set(cacheKey, { verified, expiresAt: cacheUntil }); } return { user, rawToken: token, orgId: verified.orgId }; @@ -711,128 +686,97 @@ function hasValidAdminApiKey(req: Request): boolean { return constantTimeEqual(token, ADMIN_API_KEY); } -/** - * True if `id` is a synthetic user (static admin API key or per-org WorkOS - * API key). Synthetic users don't represent a person, so they have no - * identity binding. - */ -function isSyntheticUser(id: string): boolean { - return id === 'admin_api_key' || id.startsWith('api_key_'); -} - /** * Drop any cached sessions whose WorkOS user id matches one of the given - * ids, on either auth credential (post-swap canonical or actual auth). - * Called when an identity binding changes (mergeUsers, admin rebind) so - * subsequent requests re-resolve identity instead of serving a stale swap. + * ids. This saves a provider round trip after local mutations; correctness + * comes from the fresh database snapshot on every request, on every instance. */ export function invalidateSessionsForUsers(workosUserIds: string[]): void { if (workosUserIds.length === 0) return; const ids = new Set(workosUserIds); for (const [key, value] of sessionCache.entries()) { - if (ids.has(value.user.id) || (value.user.authWorkosUserId && ids.has(value.user.authWorkosUserId))) { + if (ids.has(value.user.id)) { sessionCache.delete(key); } } for (const [key, value] of bearerJwtCache.entries()) { - if (ids.has(value.user.id) || (value.user.authWorkosUserId && ids.has(value.user.authWorkosUserId))) { + if (ids.has(value.verified.sub)) { bearerJwtCache.delete(key); } } } -/** - * Resolve the identity for a WorkOS user. Sets `user.identityId` and, when - * the authenticated user is a non-primary binding, swaps `user.id` to the - * identity's primary workos_user_id so app-state reads land on the right - * person. The original authenticated id is preserved on `user.authWorkosUserId`. - * - * Skipped for synthetic users (admin API key, WorkOS API key) — they don't - * represent a person. Failures are swallowed: identity resolution must - * never block an authenticated request, and a degraded request that sees - * only the auth user's slice of data is still better than a 500. - */ -async function attachIdentityId(user: WorkOSUser): Promise { - if (isSyntheticUser(user.id)) return; - try { - const result = await getPool().query<{ - identity_id: string; - primary_workos_user_id: string | null; - }>( - `SELECT iwu.identity_id, primary_iwu.workos_user_id AS primary_workos_user_id - FROM identity_workos_users iwu - LEFT JOIN identity_workos_users primary_iwu - ON primary_iwu.identity_id = iwu.identity_id - AND primary_iwu.is_primary = TRUE - WHERE iwu.workos_user_id = $1`, - [user.id] - ); - const row = result.rows[0]; - if (!row) return; - - user.identityId = row.identity_id; - - if (row.primary_workos_user_id && row.primary_workos_user_id !== user.id) { - // Non-primary binding signed in. Swap id so app-state reads see the - // canonical person; preserve the actual auth user on authWorkosUserId. - logger.debug( - { authWorkosUserId: user.id, canonicalUserId: row.primary_workos_user_id, identityId: row.identity_id }, - 'Identity id-swap: routing non-primary binding to canonical user' - ); - user.authWorkosUserId = user.id; - user.id = row.primary_workos_user_id; - } - } catch (err) { - logger.warn({ err, userId: user.id }, 'Failed to resolve identity_id'); +class InvalidAuthorizationCredentialError extends Error {} +class ConflictingOrganizationSelectionError extends Error {} + +/** Every explicit selector must agree with the authenticated provider selection. + * Request fields cannot switch an organization-bound credential to another org. + * Neither memberships nor caches pick an organization. */ +function selectedOrganizationForAuthentication(req: Request, providerOrg?: string): string | null { + const selectors = [ + providerOrg, + req.headers['x-organization-id'], req.query?.org, req.query?.organization_id, + req.body?.organization_id, req.body?.organizationId, + req.params?.orgId, req.params?.organizationId, + ].filter((value) => value !== undefined); + if (selectors.some((value) => typeof value !== 'string' || !value.trim())) { + throw new ConflictingOrganizationSelectionError(); } + const ids = new Set(selectors.map((value) => (value as string).trim())); + if (ids.size > 1) throw new ConflictingOrganizationSelectionError(); + return ids.values().next().value ?? null; } -/** - * Authorization fingerprint for the credential that actually authenticated - * this session. `attachIdentityId` may swap `user.id` to the identity's - * canonical credential, so the authenticated credential is read through - * `getOrganizationAuthorizationUserId` — that value is stable across the - * swap, and every identity-binding mutation bumps the epoch for each - * credential bound to the identity, so a change always moves this value. - * - * Synthetic principals (admin API key, WorkOS API key) have no credential - * row and no identity binding; they return '' and never mismatch. - */ -async function authorizationFingerprintFor( - user: WorkOSUser, -): Promise { - if (isSyntheticUser(user.id)) return ''; +/** Hydrate a new request object; never mutate the provider object in a cache. */ +async function hydrateAuthenticatedUser(user: WorkOSUser, organizationId: string | null): Promise { + const authenticatedUserId = getOrganizationAuthorizationUserId(user); + const snapshot = await loadAuthorizationSnapshot(authenticatedUserId, organizationId); + if (!snapshot) throw new InvalidAuthorizationCredentialError(); + const hydrated: WorkOSUser = { + ...user, + id: snapshot.canonicalUserId, + authWorkosUserId: snapshot.canonicalUserId !== authenticatedUserId ? authenticatedUserId : undefined, + identityId: snapshot.identityId ?? undefined, + email: snapshot.credential.email ?? '', + emailVerified: snapshot.credential.emailVerified, + firstName: snapshot.credential.firstName?.trim() || user.firstName?.trim() || undefined, + lastName: snapshot.credential.lastName?.trim() || user.lastName?.trim() || undefined, + }; + // Server-only authority must not leak through JSON responses or user spreads. + Object.defineProperty(hydrated, 'authorizationSnapshot', { value: snapshot }); + return hydrated; +} + +/** Local dev login is an explicit synthetic-auth bypass. Keep attribution useful + * against seeded databases without making a database mandatory for dev fixtures. */ +async function hydrateDevUser(user: WorkOSUser): Promise { try { - return await getAuthorizationFingerprint([getOrganizationAuthorizationUserId(user)]); - } catch (err) { - // Never fail authentication over the stamp: an unstamped entry reads as - // stale on its next hit, so the session is re-validated rather than - // served from a fingerprint we could not confirm. - logger.warn({ err }, 'Authorization epoch stamp failed — session will not be served from cache'); - return undefined; + return await hydrateAuthenticatedUser(user, null); + } catch { + return user; } } -/** - * True when a cached entry still reflects persisted authorization state. - * - * Entries cached before an identity-binding change carry the pre-change - * fingerprint and must not be served — on any instance, not just the one - * that ran the mutation. A lookup failure returns false so the request - * falls through to full re-validation rather than serving state we could - * not confirm; that degrades the cache, it does not sign anyone out. - */ -async function isAuthorizationFingerprintCurrent( - user: WorkOSUser, - stamped: string | undefined, -): Promise { - if (isSyntheticUser(user.id)) return true; - try { - return (await authorizationFingerprintFor(user)) === (stamped ?? ''); - } catch (err) { - logger.warn({ err }, 'Authorization epoch check failed — bypassing session cache'); - return false; +function sendAuthorizationStateError(error: unknown, res: Response, includeTransientErrors = false): boolean { + if (includeTransientErrors && isTransientAuthError(error)) { + logger.warn({ err: error }, 'Authentication service unavailable'); + res.status(503).json({ error: 'Authentication service temporarily unavailable' }); + return true; + } + if (error instanceof AuthorizationSnapshotUnavailableError) { + logger.warn({ err: error }, 'Primary authorization snapshot unavailable'); + res.status(503).json({ error: 'Authorization service temporarily unavailable' }); + return true; + } + if (error instanceof InvalidAuthorizationCredentialError) { + res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' }); + return true; } + if (error instanceof ConflictingOrganizationSelectionError) { + res.status(403).json({ error: 'An unambiguous organization selection is required' }); + return true; + } + return false; } /** @@ -889,7 +833,13 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio // Check for OAuth-issued user JWT (user SSO'd via AuthKit through the // MCP OAuth flow and is now calling the REST API with that token). - const jwtAuth = await validateWorkOSBearerJWT(req); + let jwtAuth: ValidatedBearerJWT | null; + try { + jwtAuth = await validateWorkOSBearerJWT(req); + } catch (error) { + if (sendAuthorizationStateError(error, res, true)) return; + throw error; + } if (jwtAuth) { logger.debug({ path: req.path, userId: jwtAuth.user.id }, 'Authenticated via OAuth user JWT'); req.user = jwtAuth.user; @@ -897,8 +847,8 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio try { const userBan = await checkPlatformBan( - `user:${jwtAuth.user.id}`, - () => bansDb.checkPlatformBan(jwtAuth.user.id), + `user:${getOrganizationAuthorizationUserId(jwtAuth.user)}`, + () => bansDb.checkPlatformBan(getOrganizationAuthorizationUserId(jwtAuth.user)), ); if (userBan) { logger.info({ userId: jwtAuth.user.id, banId: userBan.id }, 'User request blocked by platform ban'); @@ -908,7 +858,6 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio logger.warn({ err: banError, userId: jwtAuth.user.id, path: req.path }, 'Ban check failed — allowing request through'); } - await attachIdentityId(req.user); return next(); } @@ -916,14 +865,13 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio if (DEV_MODE_ENABLED) { const devUser = createDevUser(req); if (devUser) { - req.user = devUser; + req.user = await hydrateDevUser(devUser); req.accessToken = 'dev-mode-token'; // Carry over dev config flags (isMember, isAdmin) so enrichUserWithMembership skips DB lookup const devConfig = getDevUser(req); if (devConfig) { (req.user as unknown as Record).isMember = devConfig.isMember; } - await attachIdentityId(req.user); return next(); } // No dev session - redirect to dev login page @@ -978,19 +926,12 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio deadSessionCache.delete(cacheKey); } - if ( - cached && - cached.expiresAt > now && - !(await isAuthorizationFingerprintCurrent(cached.user, cached.authorizationFingerprint)) - ) { - // Identity bindings changed since this entry was stored. Drop it so - // this request re-resolves identity and organization context instead - // of inheriting pre-change authority. - sessionCache.delete(cacheKey); - } else if (cached && cached.expiresAt > now) { + if (cached && cached.expiresAt > now) { // Cache hit - use cached session data logger.debug({ userId: cached.user.id }, 'Using cached session'); - req.user = cached.user; + req.user = await hydrateAuthenticatedUser( + cached.user, selectedOrganizationForAuthentication(req, cached.orgId), + ); req.accessToken = cached.accessToken; // If session was refreshed, update the cookie @@ -1165,54 +1106,11 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio impersonator?: { email: string; reason: string | null }; }; - let firstName = result.user.firstName ?? undefined; - let lastName = result.user.lastName ?? undefined; - - // Verify the user exists in our local DB. This catches stale sessions - // from deleted/merged WorkOS accounts whose JWTs haven't expired yet. - // Also resolves names from local DB when WorkOS doesn't provide them. - try { - const pool = getPool(); - const localUser = await pool.query<{ - first_name: string | null; - last_name: string | null; - }>( - `SELECT first_name, last_name FROM users WHERE workos_user_id = $1`, - [result.user.id] - ); - if (localUser.rows.length === 0) { - logger.warn({ userId: result.user.id, email: result.user.email, path: req.path }, - 'Authenticated user not found in local DB — forcing re-login'); - sessionCache.delete(cacheKey); - if (deadSessionCache.size >= DEAD_SESSION_MAX_SIZE) { - const oldest = deadSessionCache.keys().next().value; - if (oldest) deadSessionCache.delete(oldest); - } - deadSessionCache.set(cacheKey, Date.now()); - if (isHtmlRequest) { - return res.redirect(`/auth/login?return_to=${encodeURIComponent(req.originalUrl)}`); - } - return res.status(401).json({ - error: 'Invalid session', - message: 'Your account could not be verified. Please log in again.', - login_url: '/auth/login', - }); - } - if (!firstName?.trim()) { - const row = localUser.rows[0]; - if (row.first_name?.trim()) firstName = row.first_name; - if (row.last_name?.trim()) lastName = row.last_name; - } - } catch (err) { - // Fail open: DB errors should not block authenticated users - logger.warn({ err, userId: result.user.id }, 'Local user check failed — allowing request through'); - } - const user: WorkOSUser = { id: result.user.id, email: result.user.email, - firstName, - lastName, + firstName: result.user.firstName ?? undefined, + lastName: result.user.lastName ?? undefined, emailVerified: result.user.emailVerified, createdAt: result.user.createdAt, updatedAt: result.user.updatedAt, @@ -1227,19 +1125,20 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio ); } - // Resolve identityId once before caching so cache hits inherit it. - await attachIdentityId(user); + const hydrated = await hydrateAuthenticatedUser( + user, selectedOrganizationForAuthentication(req, result.organizationId), + ); - // Cache the validated session + // Retain authentication only; derived request authority is never cached. sessionCache.set(cacheKey, { - user, + user: Object.freeze(user), accessToken: result.accessToken, expiresAt: now + SESSION_CACHE_TTL_MS, newSealedSession, - authorizationFingerprint: await authorizationFingerprintFor(user), + orgId: result.organizationId, }); - req.user = user; + req.user = hydrated; req.accessToken = result.accessToken; // Check platform ban for cookie-authenticated user (fail-open: DB timeout should not log users out) @@ -1258,6 +1157,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio next(); } catch (error) { + if (sendAuthorizationStateError(error, res)) return; if (isTransientAuthError(error)) { logger.warn({ err: error, path: req.path }, 'Transient auth upstream failure — returning 503 without clearing session'); if (isHtmlRequest) { @@ -1544,9 +1444,8 @@ export async function requireAdmin(req: Request, res: Response, next: NextFuncti if (!req.user) { const mockUser = createDevUser(req); if (mockUser) { - req.user = mockUser; + req.user = await hydrateDevUser(mockUser); req.accessToken = 'dev-mode-token'; - await attachIdentityId(req.user); } } @@ -1607,7 +1506,7 @@ export async function requireAdmin(req: Request, res: Response, next: NextFuncti const decision = decideAAOAdminAccess( await isWebUserAAOAdmin(req.user.id), - req.user.email, + req.user.authorizationSnapshot, ); if (!decision.isAdmin) { @@ -1747,7 +1646,7 @@ export function createRequireWorkingGroupLeader( // isWebUserAAOAdmin fails closed to false when its membership lookup fails. const decision = decideAAOAdminAccess( await isWebUserAAOAdmin(req.user.id), - req.user.email, + req.user.authorizationSnapshot, ); if (decision.isAdmin) { @@ -1816,7 +1715,7 @@ export function createRequireWorkingGroupMember( // isWebUserAAOAdmin fails closed to false when its membership lookup fails. const decision = decideAAOAdminAccess( await isWebUserAAOAdmin(req.user.id), - req.user.email, + req.user.authorizationSnapshot, ); if (decision.isAdmin) { @@ -1856,29 +1755,26 @@ export function createRequireWorkingGroupMember( * Native apps send: Authorization: Bearer */ function extractSealedSession(req: Request): string | undefined { - // First check for cookie (web browsers) - const sessionCookie = req.cookies['wos-session']; - if (sessionCookie) { - return sessionCookie; - } - - // Then check Authorization header (native apps like Tauri) + // An explicitly supplied bearer credential takes precedence over a cookie. + // A failed JWT must never be retried as a sealed session or replaced by a cookie. // Format: Authorization: Bearer - // Note: We differentiate from WorkOS API keys by checking prefix const authHeader = req.headers.authorization; - if (authHeader?.startsWith('Bearer ')) { + if (authHeader !== undefined) { + if (!authHeader.startsWith('Bearer ')) return undefined; const token = authHeader.slice(7); - // WorkOS API keys use known prefixes, sealed sessions don't - if (!isWorkOSApiKeyFormat(token)) { + if (token.trim() && !isWorkOSApiKeyFormat(token) && !looksLikeJWT(token)) { return token; } + return undefined; } - - return undefined; + const sessionCookie = req.cookies?.['wos-session']; + return typeof sessionCookie === 'string' && sessionCookie ? sessionCookie : undefined; } /** - * Optional auth middleware - loads user if authenticated, but doesn't require it + * Optional auth permits anonymous requests only when no credential is supplied. + * Invalid presented credentials return 401; verification or authorization + * outages return 503. Neither case falls back to anonymous or another credential. * Uses in-memory cache to reduce WorkOS API calls for session refresh * Automatically refreshes expired access tokens using the refresh token * Supports both cookie-based auth (web) and Authorization header (native apps) @@ -1919,7 +1815,13 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti return next(); } - const jwtAuth = await validateWorkOSBearerJWT(req); + let jwtAuth: ValidatedBearerJWT | null; + try { + jwtAuth = await validateWorkOSBearerJWT(req); + } catch (error) { + if (sendAuthorizationStateError(error, res, true)) return; + throw error; + } if (jwtAuth) { logger.debug({ path: req.path, userId: jwtAuth.user.id }, 'Authenticated via OAuth user JWT (optional auth)'); req.user = jwtAuth.user; @@ -1927,8 +1829,8 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti try { const userBan = await checkPlatformBan( - `user:${jwtAuth.user.id}`, - () => bansDb.checkPlatformBan(jwtAuth.user.id), + `user:${getOrganizationAuthorizationUserId(jwtAuth.user)}`, + () => bansDb.checkPlatformBan(getOrganizationAuthorizationUserId(jwtAuth.user)), ); if (userBan) { logger.info({ userId: jwtAuth.user.id, banId: userBan.id }, 'User optional-auth request blocked by platform ban'); @@ -1938,7 +1840,6 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti logger.warn({ err: banError, userId: jwtAuth.user.id, path: req.path }, 'Ban check failed — allowing optional-auth request through'); } - await attachIdentityId(req.user); return next(); } @@ -1946,22 +1847,26 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti if (DEV_MODE_ENABLED) { const devUser = createDevUser(req); if (devUser) { - req.user = devUser; + req.user = await hydrateDevUser(devUser); req.accessToken = 'dev-mode-token'; // Carry over dev config flags (isMember, isAdmin) so enrichUserWithMembership skips DB lookup const devConfig = getDevUser(req); if (devConfig) { (req.user as unknown as Record).isMember = devConfig.isMember; } + return next(); } - // No dev session = not logged in (which is fine for optional auth) - return next(); } const sessionCookie = extractSealedSession(req); + // Only complete credential absence permits anonymous access. Keep one return + // for the entire branch so CodeQL recognizes this as an early-exit guard; + // supplied but unusable credentials still return 401 without a fallback. if (!sessionCookie) { - return next(); + return req.headers.authorization !== undefined || req.cookies?.['wos-session'] !== undefined + ? res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' }) + : next(); } try { @@ -1974,21 +1879,17 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti const deadAt = deadSessionCache.get(cacheKey); if (deadAt) { if (now - deadAt < DEAD_SESSION_TTL_MS) { - return next(); // optional auth: just proceed without user + return res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' }); } deadSessionCache.delete(cacheKey); } - if ( - cached && - cached.expiresAt > now && - !(await isAuthorizationFingerprintCurrent(cached.user, cached.authorizationFingerprint)) - ) { - sessionCache.delete(cacheKey); - } else if (cached && cached.expiresAt > now) { + if (cached && cached.expiresAt > now) { // Cache hit - use cached session data logger.debug({ userId: cached.user.id }, 'Using cached session (optional auth)'); - req.user = cached.user; + req.user = await hydrateAuthenticatedUser( + cached.user, selectedOrganizationForAuthentication(req, cached.orgId), + ); req.accessToken = cached.accessToken; // If session was refreshed, update the cookie @@ -2019,6 +1920,9 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti cookiePassword: WORKOS_COOKIE_PASSWORD, }); + if (!refreshResult.authenticated && 'retryable' in refreshResult && refreshResult.retryable) { + return res.status(503).json({ error: 'Authentication service temporarily unavailable' }); + } if (refreshResult.authenticated && refreshResult.sealedSession) { logger.debug('Session refreshed successfully (optional auth)'); newSealedSession = refreshResult.sealedSession; @@ -2036,6 +1940,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti refreshFailed = true; } } catch (refreshError) { + if (isTransientAuthError(refreshError)) throw refreshError; logger.debug({ err: refreshError }, 'Optional auth refresh failed'); refreshFailed = true; } @@ -2057,6 +1962,9 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti const sharedRefresh = await sharedSessionObj.refresh({ cookiePassword: WORKOS_COOKIE_PASSWORD, }); + if (!sharedRefresh.authenticated && 'retryable' in sharedRefresh && sharedRefresh.retryable) { + return res.status(503).json({ error: 'Authentication service temporarily unavailable' }); + } if (sharedRefresh.authenticated && sharedRefresh.sealedSession) { newSealedSession = sharedRefresh.sealedSession; setSessionCookie(res, sharedRefresh.sealedSession); @@ -2070,6 +1978,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti result = await refreshedObj.authenticate(); } } catch (innerRefreshErr) { + if (isTransientAuthError(innerRefreshErr)) throw innerRefreshErr; logger.debug({ err: innerRefreshErr }, 'Shared session refresh also failed (optional auth)'); } } else { @@ -2079,6 +1988,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti } } catch (dbError) { logger.debug({ err: dbError }, 'Failed to look up shared session (optional auth)'); + return res.status(503).json({ error: 'Authentication service temporarily unavailable' }); } } } @@ -2092,6 +2002,8 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti if (oldest) deadSessionCache.delete(oldest); } deadSessionCache.set(cacheKey, Date.now()); + sessionCache.delete(cacheKey); + return res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' }); } if (result.authenticated && 'user' in result && result.user) { @@ -2119,26 +2031,26 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti ); } - // Resolve identityId before caching — sessionCache is shared with - // requireAuth, so skipping it here would let an optionalAuth request - // poison subsequent requireAuth cache hits with identityId=undefined. - await attachIdentityId(user); + const hydrated = await hydrateAuthenticatedUser( + user, selectedOrganizationForAuthentication(req, result.organizationId), + ); - // Cache the validated session + // Retain authentication only; derived request authority is never cached. sessionCache.set(cacheKey, { - user, + user: Object.freeze(user), accessToken: result.accessToken, expiresAt: now + SESSION_CACHE_TTL_MS, newSealedSession, - authorizationFingerprint: await authorizationFingerprintFor(user), + orgId: result.organizationId, }); - req.user = user; + req.user = hydrated; req.accessToken = result.accessToken; } } catch (error) { - // Silently fail for optional auth + if (sendAuthorizationStateError(error, res, true)) return; logger.debug({ err: error }, 'Optional auth failed'); + return res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' }); } next(); diff --git a/server/src/middleware/rate-limit.ts b/server/src/middleware/rate-limit.ts index 48cb015f9f9..32795e02218 100644 --- a/server/src/middleware/rate-limit.ts +++ b/server/src/middleware/rate-limit.ts @@ -2,7 +2,7 @@ import rateLimit from 'express-rate-limit'; import type { IncrementResponse, Options, Store } from 'express-rate-limit'; import type { Request, Response } from 'express'; import { createLogger } from '../logger.js'; -import { isBreakGlassAdminEmail } from '../auth/admin-access.js'; +import { isBreakGlassAdmin } from '../auth/admin-access.js'; import { CachedPostgresStore, PostgresStore, type WeightedIncrementStore } from './pg-rate-limit-store.js'; const logger = createLogger('rate-limit'); @@ -101,12 +101,12 @@ export const agentCardValidationRateLimiter = rateLimit({ * env var for emergency access, matching requireAdmin semantics. */ async function skipForAdmins(req: Request): Promise { - const user = (req as any).user as { id?: string; email?: string; isAdmin?: boolean } | undefined; + const user = (req as any).user as { id?: string; email?: string; isAdmin?: boolean; authorizationSnapshot?: import("../db/user-authorization-snapshot-db.js").AuthorizationSnapshot } | undefined; if (!user) return false; if (user.isAdmin === true) return true; - if (isBreakGlassAdminEmail(user.email)) { + if (isBreakGlassAdmin(user.authorizationSnapshot)) { return true; } diff --git a/server/src/routes/account-linking.ts b/server/src/routes/account-linking.ts index 1e9853a3051..cfcf3a65b0d 100644 --- a/server/src/routes/account-linking.ts +++ b/server/src/routes/account-linking.ts @@ -2,18 +2,33 @@ import express, { Router, Request, Response } from 'express'; import crypto from 'crypto'; import rateLimit from 'express-rate-limit'; import { createLogger } from '../logger.js'; -import { requireAuth } from '../middleware/auth.js'; +import { requireAuth, type ValidatedApiKey } from '../middleware/auth.js'; import { query, getPool } from '../db/client.js'; -import { mergeUsers } from '../db/user-merge-db.js'; import { sendEmailLinkVerification } from '../notifications/email.js'; -import { getWorkos } from '../auth/workos-client.js'; import { CachedPostgresStore } from '../middleware/pg-rate-limit-store.js'; -import { isEmailUnavailable } from './account-linking-errors.js'; +import { EmailMutationError, getEmailMutationStatus, setPrimaryEmail } from '../services/email-mutation.js'; const logger = createLogger('account-linking'); const TOKEN_EXPIRY_HOURS = 24; +async function sendEmailMutationFailure(res: Response, userId: string, error: unknown): Promise { + try { + const status = await getEmailMutationStatus(userId); + if (status.reconciliation_required) return res.status(503).json(status); + if ((error as { code?: unknown } | null)?.code === '55P03') { + return res.status(409).json({ + error: 'credential_busy', retryable: true, + message: 'An email change is already in progress. Please retry.', + }); + } + } catch { /* Status is unknown; do not invent a durable operation. */ } + return res.status(503).json({ + error: 'Failed to update email', status_unknown: true, + message: 'We could not confirm the email change. Reload to check its status or contact support before trying again.', + }); +} + // Rate limiter for sending verification emails (per authenticated user) const sendVerificationLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour @@ -36,7 +51,7 @@ const verifyViewLimiter = rateLimit({ validate: { keyGeneratorIpFallback: false }, }); -// Rate limiter for verification execution (strict — destructive action) +// Rate limiter for verification and email mutation execution const verifyExecuteLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, @@ -57,8 +72,10 @@ export function createAccountLinkingRouter(): Router { // GET /api/me/linked-emails — list linked emails and pending tokens router.get('/', requireAuth, async (req: Request, res: Response) => { try { - const userId = req.user!.id; + const userId = req.user!.authWorkosUserId ?? req.user!.id; + const mutationStatus = await getEmailMutationStatus(userId); + const credential = await query('SELECT email FROM users WHERE workos_user_id = $1', [userId]); const aliases = await query( `SELECT id, email, verified_at, created_at FROM user_email_aliases @@ -76,12 +93,14 @@ export function createAccountLinkingRouter(): Router { ); return res.json({ - primary_email: req.user!.email, + credential_id: userId, + primary_email: credential.rows[0]?.email, + ...mutationStatus, aliases: aliases.rows, pending: pending.rows, }); - } catch (error) { - logger.error({ error }, 'Failed to list linked emails'); + } catch { + logger.error('Failed to list linked emails'); return res.status(500).json({ error: 'Failed to list linked emails' }); } }); @@ -89,7 +108,9 @@ export function createAccountLinkingRouter(): Router { // POST /api/me/linked-emails — initiate email link verification router.post('/', requireAuth, sendVerificationLimiter, async (req: Request, res: Response) => { try { - const userId = req.user!.id; + const userId = req.user!.authWorkosUserId ?? req.user!.id; + const mutationStatus = await getEmailMutationStatus(userId); + if (mutationStatus.reconciliation_required) return res.status(409).json(mutationStatus); const { email } = req.body; if (!email || typeof email !== 'string') { @@ -108,7 +129,8 @@ export function createAccountLinkingRouter(): Router { } // Can't link your own email - if (normalizedEmail === req.user!.email.toLowerCase()) { + const credential = await query('SELECT email FROM users WHERE workos_user_id = $1', [userId]); + if (normalizedEmail === credential.rows[0]?.email.toLowerCase()) { return res.status(400).json({ error: 'This is already your primary email' }); } @@ -195,7 +217,7 @@ export function createAccountLinkingRouter(): Router { to: normalizedEmail, token, primaryUserName: primaryName, - primaryEmail: req.user!.email, + primaryEmail: credential.rows[0]?.email ?? req.user!.email, }); logger.info( @@ -209,139 +231,32 @@ export function createAccountLinkingRouter(): Router { status: 'verification_sent', }); } catch (error) { - logger.error({ error }, 'Failed to initiate email link'); - return res.status(500).json({ error: 'Failed to initiate email link' }); + logger.error('Failed to initiate email link'); + return sendEmailMutationFailure(res, req.user!.authWorkosUserId ?? req.user!.id, error); } }); - // PUT /api/me/linked-emails/primary — swap a linked alias to be the primary email + // Email ownership and the provider mutation are scoped to the credential + // that authenticated, even when the session also carries a canonical person. router.put('/primary', requireAuth, verifyExecuteLimiter, async (req: Request, res: Response) => { + // Only a real member credential hydrated from the primary database can + // change email. Synthetic/API-key principals are not email credentials. + const snapshot = req.user?.authorizationSnapshot; + if ((req as Request & { apiKey?: ValidatedApiKey }).apiKey || !snapshot + || snapshot.authenticatedUserId !== (req.user!.authWorkosUserId ?? req.user!.id)) { + return res.status(403).json({ error: 'A member login is required to change primary email' }); + } + const userId = snapshot.authenticatedUserId; try { - const userId = req.user!.id; - const { email } = req.body; - - if (!email || typeof email !== 'string') { - return res.status(400).json({ error: 'Email is required' }); - } - - const normalizedEmail = email.trim().toLowerCase(); - const currentPrimary = req.user!.email.toLowerCase(); - - if (normalizedEmail === currentPrimary) { - return res.status(400).json({ error: 'This is already your primary email' }); - } - - const oldPrimary = req.user!.email; - - // Verify the alias exists before touching WorkOS so we don't change - // WorkOS state for an email the user hasn't actually verified. - const aliasRead = await query( - `SELECT email FROM user_email_aliases - WHERE workos_user_id = $1 AND LOWER(email) = $2 AND verified_at IS NOT NULL`, - [userId, normalizedEmail] - ); - if (aliasRead.rows.length === 0) { - return res.status(404).json({ error: 'Email is not linked to your account' }); - } - const aliasEmail: string = aliasRead.rows[0].email; - - // WorkOS first (source of truth for auth). If WorkOS rejects, our DB is - // unchanged and we surface a clear error. - // - // If WorkOS accepts but the local swap below fails, `users.email` and - // `organization_memberships.email` will eventually re-sync via the - // user.updated webhook. `user_email_aliases` and `person_relationships` - // are NOT touched by the webhook, so a partial failure here can leave - // the alias list stale (new email remains as a verified alias, old - // primary is not recorded) and the relationship row's email lagging. - // UNIQUE(LOWER(email)) on aliases prevents anyone else from claiming - // the abandoned email; cleanup is manual. - try { - // Mutate the actually-signed-in WorkOS user's email, not the - // post-swap canonical. Email is per-credential. - const workosTargetUserId = req.user!.authWorkosUserId ?? userId; - await getWorkos().userManagement.updateUser({ userId: workosTargetUserId, email: aliasEmail }); - } catch (workosError: any) { - if (isEmailUnavailable(workosError)) { - return res.status(409).json({ error: 'This email is already associated with another account in our auth system' }); - } - throw workosError; - } - - const pool = getPool(); - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - // Re-lock and re-verify in case the alias changed between the read - // above and now. - const alias = await client.query( - `SELECT email FROM user_email_aliases - WHERE workos_user_id = $1 AND LOWER(email) = $2 AND verified_at IS NOT NULL - FOR UPDATE`, - [userId, normalizedEmail] - ); - - if (alias.rows.length === 0) { - await client.query('ROLLBACK'); - // WorkOS already points at the new email; the user.updated webhook - // will re-sync users.email and organization_memberships.email. The - // alias table will remain stale until manual cleanup. - logger.warn( - { userId, normalizedEmail }, - 'Alias disappeared between WorkOS update and DB swap; users.email will reconcile via webhook, aliases need manual cleanup' - ); - return res.status(409).json({ error: 'Email link state changed; please refresh and try again' }); - } - - await client.query( - `UPDATE users SET email = $1, updated_at = NOW() WHERE workos_user_id = $2`, - [aliasEmail, userId] - ); - - await client.query( - `DELETE FROM user_email_aliases WHERE workos_user_id = $1 AND LOWER(email) = $2`, - [userId, normalizedEmail] - ); - - await client.query( - `INSERT INTO user_email_aliases (workos_user_id, email) - VALUES ($1, $2) - ON CONFLICT DO NOTHING`, - [userId, oldPrimary] - ); - - await client.query( - `UPDATE organization_memberships SET email = $1, updated_at = NOW() - WHERE workos_user_id = $2 - AND email IS DISTINCT FROM $1`, - [aliasEmail, userId] - ); - - await client.query( - `UPDATE person_relationships SET email = $1, updated_at = NOW() - WHERE workos_user_id = $2 - AND email IS DISTINCT FROM $1`, - [aliasEmail, userId] - ); - - await client.query('COMMIT'); - } catch (err) { - await client.query('ROLLBACK'); - throw err; - } finally { - client.release(); - } - - logger.info( - { userId, oldPrimary, newPrimary: aliasEmail }, - 'Primary email changed' - ); - - return res.json({ status: 'primary_updated', primary_email: aliasEmail }); - } catch (error: any) { - logger.error({ error }, 'Failed to set primary email'); - return res.status(500).json({ error: 'Failed to update primary email' }); + const result = await setPrimaryEmail({ + userId, email: req.body?.email, actorUserId: userId, operationId: req.body?.operation_id, + }); + return res.json(result); + } catch (error) { + if (error instanceof EmailMutationError) return res.status(error.status).json(error.body); + // Provider exceptions may contain request headers, tokens, or bodies. + logger.error('Failed to set primary email'); + return sendEmailMutationFailure(res, userId, error); } }); @@ -350,7 +265,7 @@ export function createAccountLinkingRouter(): Router { /** * Public verify endpoints — no auth required (opened from email inbox). - * GET renders a confirmation page, POST executes the merge. + * GET renders a confirmation page, POST records a verified email alias. */ export function handleEmailLinkVerification(app: { get: (path: string, ...handlers: any[]) => void; @@ -395,13 +310,13 @@ export function handleEmailLinkVerification(app: { hasMerge: !!tokenRecord.target_workos_user_id, token: token as string, }); - } catch (error) { - logger.error({ error }, 'Email link verification page failed'); + } catch { + logger.error('Email link verification page failed'); return renderVerifyPage(res, { success: false, message: 'Something went wrong. Please try again or contact support.' }); } }); - // POST /verify-email-link — execute the merge (protected by FOR UPDATE lock) + // POST /verify-email-link — verify the alias (protected by FOR UPDATE lock) app.post('/verify-email-link', express.urlencoded({ extended: false }), verifyExecuteLimiter, async (req: Request, res: Response) => { const { token } = req.body; @@ -442,6 +357,12 @@ export function handleEmailLinkVerification(app: { return renderVerifyPage(res, { success: false, message: 'This verification link has expired. Please request a new one from your dashboard settings.' }); } + const mutationStatus = await getEmailMutationStatus(tokenRecord.primary_workos_user_id, client); + if (mutationStatus.reconciliation_required) { + await client.query('ROLLBACK'); + return renderVerifyPage(res, { success: false, message: mutationStatus.message! }); + } + // Mark token as processing (prevents concurrent attempts, but allows retry on failure) await client.query( `UPDATE email_link_tokens SET status = 'processing' WHERE id = $1`, @@ -493,8 +414,8 @@ export function handleEmailLinkVerification(app: { await query( `UPDATE email_link_tokens SET status = 'pending' WHERE id = $1`, [tokenRecord.id] - ).catch((resetErr) => { - logger.error({ error: resetErr, tokenId: tokenRecord.id }, 'Failed to reset token status after verify error — user cannot retry'); + ).catch(() => { + logger.error({ tokenId: tokenRecord.id }, 'Failed to reset token status after verify error — user cannot retry'); }); throw verifyError; } @@ -507,9 +428,9 @@ export function handleEmailLinkVerification(app: { ); return renderVerifyPage(res, { success: true, message }); - } catch (error: any) { + } catch { await client.query('ROLLBACK').catch(() => {}); - logger.error({ error, errorMessage: error?.message, errorCode: error?.code }, 'Email link verification failed'); + logger.error('Email link verification failed'); return renderVerifyPage(res, { success: false, message: 'Something went wrong during verification. Please try again or contact support.' }); } finally { client.release(); @@ -524,7 +445,7 @@ function escapeHtml(str: string): string { function renderConfirmPage(res: Response, opts: { targetEmail: string; hasMerge: boolean; token: string }): void { const mergeWarning = opts.hasMerge ? `

- An existing account was found with this email. Confirming will combine the two accounts so that signing in with either email leads to the same workspace. + An existing account was found with this email. These accounts remain separate. Please contact support for assistance.

` : ''; diff --git a/server/src/routes/events.ts b/server/src/routes/events.ts index c382fe39a62..5d1bfe9ac0d 100644 --- a/server/src/routes/events.ts +++ b/server/src/routes/events.ts @@ -41,7 +41,7 @@ import { createChannel, setChannelPurpose, sendDirectMessage } from "../slack/cl import { SlackDatabase } from "../db/slack-db.js"; import { EmailPreferencesDatabase } from "../db/email-preferences-db.js"; import { isWebUserAAOAdmin } from "../addie/admin-status-lookup.js"; -import { isBreakGlassAdminEmail } from "../auth/admin-access.js"; +import { isBreakGlassAdmin } from "../auth/admin-access.js"; import { getWorkos } from "../auth/workos-client.js"; import { resolveUserOrgMembership } from "../utils/resolve-user-org-membership.js"; @@ -1742,7 +1742,7 @@ export function createEventsRouter(): { if (!["published", "completed"].includes(event.status)) { const user = req.user; const isAdmin = !!user && ( - isBreakGlassAdminEmail(user.email) || + isBreakGlassAdmin(user.authorizationSnapshot) || await isWebUserAAOAdmin(user.id) ); if (!isAdmin) { diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index 06d2b7396ae..210cb656d0e 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -197,7 +197,7 @@ import { getRequestLog, getRequestCount, logOutboundRequest } from "../db/outbou import { enrichUserWithMembership } from "../utils/html-config.js"; import { classifyProbeError } from "../utils/probe-error.js"; import { isWebUserAAOAdmin } from "../addie/admin-status-lookup.js"; -import { isBreakGlassAdminEmail } from "../auth/admin-access.js"; +import { isBreakGlassAdmin } from "../auth/admin-access.js"; import { getDevUser, isDevModeEnabled } from "../middleware/auth.js"; import { OrganizationDatabase, hasApiAccess, resolveMembershipTier } from "../db/organization-db.js"; import { resolveCallerOrgId } from "./helpers/resolve-caller-org.js"; @@ -7359,14 +7359,14 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { async function isRegistryAdminRequest(req: Request): Promise { if (isStaticAdminRequest(req)) return true; - const user = req.user as ({ id?: string; email?: string; isAdmin?: boolean } | undefined); + const user = req.user as ({ id?: string; email?: string; isAdmin?: boolean; authorizationSnapshot?: import("../db/user-authorization-snapshot-db.js").AuthorizationSnapshot } | undefined); if (!user) return false; if (user.isAdmin === true) return true; const devUser = isDevModeEnabled() ? getDevUser(req) : null; if (devUser?.isAdmin === true) return true; - if (isBreakGlassAdminEmail(user.email)) return true; + if (isBreakGlassAdmin(user.authorizationSnapshot)) return true; if (!user.id) return false; return isWebUserAAOAdmin(user.id); } diff --git a/server/src/services/email-mutation.ts b/server/src/services/email-mutation.ts new file mode 100644 index 00000000000..9b830693974 --- /dev/null +++ b/server/src/services/email-mutation.ts @@ -0,0 +1,366 @@ +import { createHmac } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import type { PoolClient } from 'pg'; +import { getDedicatedClient, getPool } from '../db/client.js'; +import { getEmailMutationWorkos } from '../auth/workos-client.js'; +import { createLogger } from '../logger.js'; +import { isEmailUnavailable } from '../routes/account-linking-errors.js'; + +const logger = createLogger('email-mutation'); +export const EMAIL_RECONCILIATION_MESSAGE = 'Your email change needs support reconciliation with our sign-in provider. Email changes are disabled until support confirms the outcome. Please contact support.'; +type ResultBody = { operation_id?: string; error?: string; message?: string; reconciliation_required?: boolean; retryable?: boolean; status?: string; primary_email?: string }; +interface EmailMutation { + id: string; + workos_user_id: string; + actor_user_id: string; + payload_hash: string; + old_email: string; + old_email_verified: boolean; + new_email: string; + expected_email_version: string; + applied_email_version: string | null; + epoch_after: string | null; + state: 'pending' | 'succeeded' | 'compensated' | 'reconciliation_required'; + failure_code: string | null; + result_status: number; + result_body: ResultBody; +} +type QueryClient = Pick; +class CommitUnknown extends Error {} + +/** Stable request identity, not password storage. The domain key is public and + * fixed so retries on different replicas/deployments compare the same payload. */ +function emailMutationRequestFingerprint(userId: string, normalizedEmail: string): string { + return createHmac('sha256', 'adcp:member-primary-email:idempotency:v1') + .update(JSON.stringify([userId, normalizedEmail])).digest('hex'); +} + +export class EmailMutationError extends Error { + constructor(public status: number, public body: ResultBody) { super(body.message ?? body.error); } +} +function reconciliationError(operationId: string): EmailMutationError { + return new EmailMutationError(409, { error: 'Email reconciliation required', message: EMAIL_RECONCILIATION_MESSAGE, reconciliation_required: true, operation_id: operationId }); +} +function busyError(): EmailMutationError { + return new EmailMutationError(409, { error: 'credential_busy', message: 'An email change is already in progress. Please retry.', retryable: true }); +} +function unrecordedError(): EmailMutationError { + return new EmailMutationError(503, { error: 'Email change unavailable', message: 'Your email change could not be recorded. Please retry with the same request.', retryable: true }); +} +function exactOne(result: { rowCount: number | null; rows: unknown[] }): void { + if (result.rowCount !== 1 || result.rows.length !== 1) throw new Error('Expected exactly one affected row'); +} +async function readOperation(client: QueryClient, id: string): Promise { + const result = await client.query('SELECT * FROM email_mutations WHERE id = $1', [id]); + if (result.rowCount === 0 && result.rows.length === 0) return undefined; + exactOne(result); + return result.rows[0]; +} +const evidenceFields: Array = ['id', 'workos_user_id', 'actor_user_id', 'payload_hash', 'old_email', 'old_email_verified', 'new_email', 'expected_email_version', 'applied_email_version', 'epoch_after', 'state', 'failure_code', 'result_status', 'result_body']; +function assertEvidence(actual: EmailMutation | undefined, expected: EmailMutation): void { + if (!actual || evidenceFields.some(field => !isDeepStrictEqual(actual[field], expected[field]))) throw new Error('Email mutation evidence differs'); +} +async function insertIntent(client: PoolClient, operation: EmailMutation): Promise { + try { + await client.query('BEGIN'); + const inserted = await client.query( + `INSERT INTO email_mutations (id, workos_user_id, actor_user_id, payload_hash, old_email, old_email_verified, + new_email, expected_email_version, state, failure_code, result_status, result_body) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING *`, + [operation.id, operation.workos_user_id, operation.actor_user_id, operation.payload_hash, operation.old_email, + operation.old_email_verified, operation.new_email, operation.expected_email_version, operation.state, + operation.failure_code, operation.result_status, operation.result_body], + ); + exactOne(inserted); + assertEvidence(inserted.rows[0], operation); + assertEvidence(await readOperation(client, operation.id), operation); + let acknowledged = false; + try { acknowledged = (await client.query('COMMIT')).command === 'COMMIT'; } catch { /* Confirm on a fresh connection. */ } + if (!acknowledged) { + await client.query('ROLLBACK').catch(() => undefined); + } + const verifier = await getDedicatedClient(); + try { assertEvidence(await readOperation(verifier, operation.id), operation); } + finally { await verifier.end(); } + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined); + throw error; + } +} +async function updateJournal(client: PoolClient, operation: EmailMutation): Promise { + const updated = await client.query( + `UPDATE email_mutations SET state=$3, failure_code=$4, result_status=$5, result_body=$6, + applied_email_version=$7, epoch_after=$8, updated_at=NOW() + WHERE id=$1 AND workos_user_id=$2 AND state IN ('pending','reconciliation_required') RETURNING *`, + [operation.id, operation.workos_user_id, operation.state, operation.failure_code, operation.result_status, + operation.result_body, operation.applied_email_version, operation.epoch_after], + ); + exactOne(updated); + assertEvidence(updated.rows[0], operation); + assertEvidence(await readOperation(client, operation.id), operation); +} +export async function getEmailMutationStatus(userId: string, client?: QueryClient): Promise<{ reconciliation_required: boolean; message?: string; operation_id?: string }> { + const result = await (client ?? getPool()).query<{ id: string }>( + `SELECT id FROM email_mutations WHERE workos_user_id=$1 AND state IN ('pending','reconciliation_required') LIMIT 1`, [userId], + ); + return result.rows[0] ? { reconciliation_required: true, message: EMAIL_RECONCILIATION_MESSAGE, operation_id: result.rows[0].id } : { reconciliation_required: false }; +} +async function withCredentialLock(userId: string, work: (client: PoolClient) => Promise): Promise { + const client = await getPool().connect(); + let locked = false; + let discard = true; + try { + const result = await client.query<{ locked: boolean }>('SELECT pg_try_advisory_lock(hashtextextended($1, 6827)) AS locked', [userId]); + const lockResult = result.rows[0]?.locked; + locked = lockResult === true; + discard = typeof lockResult !== 'boolean'; + if (!locked) throw busyError(); + return await work(client); + } finally { + if (locked) { + try { + const result = await client.query<{ unlocked: boolean }>('SELECT pg_advisory_unlock(hashtextextended($1, 6827)) AS unlocked', [userId]); + discard = result.rows[0]?.unlocked !== true; + } catch { discard = true; } + if (discard) logger.error({ userId, code: 'advisory_unlock_failed' }, 'Discarding email mutation connection'); + } + client.release(discard); + } +} +async function recordFailure(client: PoolClient, operation: EmailMutation, code: string): Promise { + try { + await client.query('BEGIN'); + const epoch = await bumpEpoch(client, operation.workos_user_id); + const failure: EmailMutation = { ...operation, state: 'reconciliation_required', failure_code: code, + applied_email_version: null, epoch_after: epoch, result_status: 409, result_body: operation.result_body }; + await updateJournal(client, failure); + let acknowledged = false; + try { acknowledged = (await client.query('COMMIT')).command === 'COMMIT'; } catch { /* Verify independently. */ } + if (!acknowledged) await client.query('ROLLBACK').catch(() => undefined); + const verifier = await getDedicatedClient(); + try { + assertEvidence(await readOperation(verifier, operation.id), failure); + const savedEpoch = await verifier.query<{ epoch: string }>('SELECT epoch FROM authorization_epochs WHERE workos_user_id=$1', [operation.workos_user_id]); + exactOne(savedEpoch); + if (savedEpoch.rows[0].epoch !== epoch) throw new Error('Reconciliation epoch unproven'); + } finally { await verifier.end(); } + await invalidateCredential(operation.workos_user_id); + } catch { await client.query('ROLLBACK').catch(() => undefined); /* The durable intent remains unresolved. */ } + logger.warn({ operationId: operation.id, userId: operation.workos_user_id, code }, 'Email mutation requires reconciliation'); +} +function definitiveRejection(error: unknown): boolean { + const status = (error as { status?: unknown } | null)?.status; + return typeof status === 'number' && [400, 401, 403, 404, 409, 422].includes(status); +} +async function invalidateCredential(userId: string): Promise { + try { + const { invalidateSessionsForUsers } = await import('../middleware/auth.js'); + invalidateSessionsForUsers([userId]); + } catch { logger.error({ userId, code: 'session_eviction_failed' }, 'Persisted credential epoch requires session revalidation'); } +} +async function outcome(operation: EmailMutation): Promise<{ primary_email: string; operation_id: string; status: string }> { + if (operation.result_body.operation_id !== operation.id) throw new Error('Recorded operation result differs'); + if (operation.state === 'succeeded' || operation.state === 'compensated') { + await committedEvidence(operation, true); + await invalidateCredential(operation.workos_user_id); + } + if (operation.result_status !== 200) throw new EmailMutationError(operation.result_status, operation.result_body); + if (typeof operation.result_body.primary_email !== 'string' || operation.result_body.operation_id !== operation.id || operation.result_body.status !== 'primary_updated') throw new Error('Invalid recorded email outcome'); + return { primary_email: operation.result_body.primary_email, operation_id: operation.id, status: 'primary_updated' }; +} +async function bumpEpoch(client: PoolClient, userId: string): Promise { + const before = await client.query<{ epoch: string }>('SELECT epoch FROM authorization_epochs WHERE workos_user_id=$1 FOR UPDATE', [userId]); + if (before.rowCount !== before.rows.length || before.rows.length > 1) throw new Error('Invalid credential epoch'); + const expected = (BigInt(before.rows[0]?.epoch ?? '0') + 1n).toString(); + const bumped = await client.query<{ workos_user_id: string; epoch: string }>( + `INSERT INTO authorization_epochs (workos_user_id, epoch) VALUES ($1,1) + ON CONFLICT (workos_user_id) DO UPDATE SET epoch=authorization_epochs.epoch+1, updated_at=NOW() + RETURNING workos_user_id, epoch`, [userId], + ); + exactOne(bumped); + if (bumped.rows[0].workos_user_id !== userId || bumped.rows[0].epoch !== expected) throw new Error('Credential epoch did not advance'); + const verified = await client.query<{ epoch: string }>('SELECT epoch FROM authorization_epochs WHERE workos_user_id=$1', [userId]); + exactOne(verified); + if (verified.rows[0].epoch !== expected) throw new Error('Credential epoch readback differs'); + return expected; +} +async function localEmail(client: PoolClient, operation: EmailMutation, restore: boolean): Promise { + await client.query("SELECT set_config('adcp.email_mutation_id',$1,true)", [operation.id]); + const email = restore ? operation.old_email : operation.new_email; + const verified = restore ? operation.old_email_verified : true; + const updated = await client.query<{ email: string; email_verified: boolean; email_mutation_version: string }>( + `UPDATE users SET email=$1, email_verified=$3, updated_at=NOW() WHERE workos_user_id=$2 + RETURNING email, email_verified, email_mutation_version`, [email, operation.workos_user_id, verified], + ); + exactOne(updated); + const version = (BigInt(operation.expected_email_version) + 1n).toString(); + if (updated.rows[0].email !== email || updated.rows[0].email_verified !== verified || updated.rows[0].email_mutation_version !== version) throw new Error('Credential email write differs'); + const readback = await client.query('SELECT email, email_verified, email_mutation_version FROM users WHERE workos_user_id=$1', [operation.workos_user_id]); + exactOne(readback); + if (!isDeepStrictEqual(readback.rows[0], updated.rows[0])) throw new Error('Credential email readback differs'); + if (!restore) { + const removed = await client.query('DELETE FROM user_email_aliases WHERE workos_user_id=$1 AND LOWER(email)=$2 AND verified_at IS NOT NULL RETURNING email', [operation.workos_user_id, operation.new_email.toLowerCase()]); + exactOne(removed); + const added = await client.query( + `INSERT INTO user_email_aliases (workos_user_id,email,verified_at) VALUES ($1,$2,CASE WHEN $3 THEN NOW() ELSE NULL END) + ON CONFLICT (workos_user_id,email) DO UPDATE SET verified_at=EXCLUDED.verified_at RETURNING email`, + [operation.workos_user_id, operation.old_email, operation.old_email_verified], + ); + exactOne(added); + } + await client.query('UPDATE organization_memberships SET email=$1, updated_at=NOW() WHERE workos_user_id=$2 AND email IS DISTINCT FROM $1', [email, operation.workos_user_id]); + await client.query('UPDATE person_relationships SET email=$1, updated_at=NOW() WHERE workos_user_id=$2 AND email IS DISTINCT FROM $1', [email, operation.workos_user_id]); + const stale = await client.query( + `SELECT 1 FROM organization_memberships WHERE workos_user_id=$1 AND email IS DISTINCT FROM $2 + UNION ALL SELECT 1 FROM person_relationships WHERE workos_user_id=$1 AND email IS DISTINCT FROM $2 LIMIT 1`, + [operation.workos_user_id, email], + ); + if (stale.rowCount !== 0 || stale.rows.length !== 0) throw new Error('Email denormalizations differ'); + const aliases = await client.query<{ email: string; verified: boolean }>( + `SELECT LOWER(email) AS email, verified_at IS NOT NULL AS verified FROM user_email_aliases + WHERE workos_user_id=$1 AND LOWER(email)=ANY($2)`, + [operation.workos_user_id, [operation.old_email.toLowerCase(), operation.new_email.toLowerCase()]], + ); + if (restore) { + if (!aliases.rows.some(alias => alias.email === operation.new_email.toLowerCase() && alias.verified)) throw new Error('Original verified alias is missing'); + } else if (aliases.rowCount !== 1 || aliases.rows.length !== 1 + || aliases.rows[0].email !== operation.old_email.toLowerCase() || aliases.rows[0].verified !== operation.old_email_verified) { + throw new Error('Alias swap readback differs'); + } + return version; +} +async function committedEvidence(operation: EmailMutation, allowLaterVersion = false): Promise { + // Independent connection: an old value is never evidence that COMMIT cannot + // finish later. Only this exact terminal marker proves a completed outcome. + const verifier = await getDedicatedClient(); + try { + assertEvidence(await readOperation(verifier, operation.id), operation); + const epoch = await verifier.query<{ epoch: string }>('SELECT epoch FROM authorization_epochs WHERE workos_user_id=$1', [operation.workos_user_id]); + exactOne(epoch); + if (operation.epoch_after === null || BigInt(epoch.rows[0].epoch) < BigInt(operation.epoch_after)) throw new Error('Committed epoch not proven'); + const user = await verifier.query<{ email_mutation_version: string; email: string; email_verified: boolean }>('SELECT email_mutation_version, email, email_verified FROM users WHERE workos_user_id=$1', [operation.workos_user_id]); + exactOne(user); + const expectedEmail = operation.state === 'succeeded' ? operation.new_email : operation.old_email; + const expectedVerified = operation.state === 'succeeded' ? true : operation.old_email_verified; + const currentVersion = BigInt(user.rows[0].email_mutation_version); + const appliedVersion = BigInt(operation.applied_email_version ?? '-1'); + if (appliedVersion !== BigInt(operation.expected_email_version) + 1n || currentVersion < appliedVersion + || (!allowLaterVersion && currentVersion !== appliedVersion) + || (currentVersion === appliedVersion && (user.rows[0].email !== expectedEmail || user.rows[0].email_verified !== expectedVerified))) throw new Error('Committed email version not proven'); + } finally { await verifier.end(); } +} +async function terminal(client: PoolClient, operation: EmailMutation, restore: boolean, status: number, body: ResultBody, failureCode: string | null): Promise { + let commitAttempted = false; + try { + await client.query('BEGIN'); + const applied = await localEmail(client, operation, restore); + const epoch = await bumpEpoch(client, operation.workos_user_id); + const finished: EmailMutation = { ...operation, state: restore ? 'compensated' : 'succeeded', failure_code: failureCode, + result_status: status, result_body: body, applied_email_version: applied, epoch_after: epoch }; + await updateJournal(client, finished); + commitAttempted = true; + let acknowledged = false; + try { acknowledged = (await client.query('COMMIT')).command === 'COMMIT'; } catch { /* Verify the durable marker below. */ } + if (!acknowledged) await client.query('ROLLBACK').catch(() => undefined); + try { await committedEvidence(finished); } catch { throw new CommitUnknown('Terminal commit outcome unproven'); } + return finished; + } catch (error) { + if (commitAttempted) throw new CommitUnknown('Terminal commit outcome unproven'); + const rolledBack = await client.query('ROLLBACK').then(result => result.command === 'ROLLBACK', () => false); + if (!rolledBack) throw new CommitUnknown('Local rollback outcome unproven'); + throw error; + } +} +async function compensate(client: PoolClient, operation: EmailMutation): Promise { + try { + // Re-prove the durable intent immediately before a compensating provider write. + const current = await readOperation(client, operation.id); + if (!current || !['pending','reconciliation_required'].includes(current.state)) throw new Error('Compensation intent missing'); + assertEvidence(current, { ...operation, state: current.state, failure_code: current.failure_code, epoch_after: current.epoch_after }); + const restored = await getEmailMutationWorkos().userManagement.updateUser({ userId: operation.workos_user_id, email: operation.old_email, emailVerified: operation.old_email_verified }); + if (restored.id !== operation.workos_user_id || restored.email.toLowerCase() !== operation.old_email.toLowerCase() || restored.emailVerified !== operation.old_email_verified) throw new Error('Provider restore unproven'); + return await terminal(client, operation, true, 503, { + error: 'Email change failed', message: 'Your previous email was restored with our sign-in provider. Please start a new request to try again later or contact support.', operation_id: operation.id, reconciliation_required: false, + }, 'local_write_failed'); + } catch { + await recordFailure(client, operation, 'compensation_failed'); + throw reconciliationError(operation.id); + } +} + +/** Exact-credential mutation. Unresolved operations have no support bypass. */ +export async function setPrimaryEmail(input: { userId: string; email: unknown; operationId: unknown; actorUserId?: string }): Promise<{ primary_email: string; operation_id: string; status: string }> { + if (typeof input.operationId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(input.operationId)) throw new EmailMutationError(400, { error: 'A valid operation_id UUID is required' }); + if (typeof input.email !== 'string' || !input.email.trim()) throw new EmailMutationError(400, { error: 'Email is required' }); + const email = input.email.trim().toLowerCase(); + const at = email.indexOf('@'); const dot = email.lastIndexOf('.'); + if (email.length > 255 || /\s/.test(email) || at < 1 || at !== email.lastIndexOf('@') || dot <= at+1 || dot === email.length-1) throw new EmailMutationError(400, { error: 'Invalid email address' }); + const operationId = input.operationId.toLowerCase(); + return withCredentialLock(input.userId, async client => { + const previous = await readOperation(client, operationId); + if (previous) { + if (previous.workos_user_id !== input.userId || previous.payload_hash !== emailMutationRequestFingerprint(previous.workos_user_id, email)) throw new EmailMutationError(409, { error: 'operation_id_reused', message: 'This request identifier belongs to a different email change.' }); + if (previous.state === 'pending') await recordFailure(client, previous, 'interrupted_request'); + return outcome(previous); + } + const unresolved = await getEmailMutationStatus(input.userId, client); + if (unresolved.reconciliation_required) throw reconciliationError(unresolved.operation_id!); + const local = await client.query<{ workos_user_id: string; email: string; email_verified: boolean; email_mutation_version: string }>('SELECT workos_user_id, email, email_verified, email_mutation_version FROM users WHERE workos_user_id=$1', [input.userId]); + if (!local.rows[0]) throw new EmailMutationError(404, { error: 'User not found' }); + exactOne(local); + if (local.rows[0].workos_user_id !== input.userId) throw new Error('Credential row does not match requested user'); + const payloadHash = emailMutationRequestFingerprint(local.rows[0].workos_user_id, email); + if (local.rows[0].email.toLowerCase() === email) throw new EmailMutationError(400, { error: 'This is already your primary email' }); + const alias = await client.query<{ email: string }>('SELECT email FROM user_email_aliases WHERE workos_user_id=$1 AND LOWER(email)=$2 AND verified_at IS NOT NULL', [input.userId, email]); + if (!alias.rows[0]) throw new EmailMutationError(404, { error: 'Email is not linked to your account' }); + exactOne(alias); + const operation: EmailMutation = { + id: operationId, workos_user_id: local.rows[0].workos_user_id, actor_user_id: input.actorUserId ?? input.userId, payload_hash: payloadHash, + old_email: local.rows[0].email, old_email_verified: local.rows[0].email_verified, new_email: alias.rows[0].email, + expected_email_version: local.rows[0].email_mutation_version, applied_email_version: null, epoch_after: null, + state: 'pending', failure_code: null, result_status: 409, result_body: reconciliationError(operationId).body, + }; + try { await insertIntent(client, operation); } catch { + // Different credentials hold different locks. A concurrent UUID claimant + // can win after our initial lookup but before this INSERT reaches its key. + let existing: EmailMutation | undefined; + try { existing = await readOperation(client, operation.id); } catch { /* Unknown remains unavailable. */ } + if (existing && (existing.workos_user_id !== input.userId || existing.payload_hash !== payloadHash)) { + throw new EmailMutationError(409, { error: 'operation_id_reused', message: 'This request identifier belongs to a different email change.' }); + } + if (existing?.state === 'pending') { + await recordFailure(client, existing, 'intent_confirmation_failed'); + throw reconciliationError(existing.id); + } + throw unrecordedError(); + } + let original; + try { original = await getEmailMutationWorkos().userManagement.getUser(input.userId); } catch { + // An unavailable baseline cannot prove agreement with the provider. Keep + // the credential unresolved instead of clearing the authority fence. + await recordFailure(client, operation, 'provider_read_failed'); + throw reconciliationError(operation.id); + } + if (original.id !== input.userId || original.email.toLowerCase() !== operation.old_email.toLowerCase() || original.emailVerified !== operation.old_email_verified) { + await recordFailure(client, operation, 'preexisting_provider_mismatch'); throw reconciliationError(operation.id); + } + // Recheck full durable evidence after the provider read and before mutation. + try { assertEvidence(await readOperation(client, operation.id), operation); } catch { throw reconciliationError(operation.id); } + let changed; + try { changed = await getEmailMutationWorkos().userManagement.updateUser({ userId: input.userId, email: operation.new_email, emailVerified: true }); } + catch (error) { + if (!definitiveRejection(error)) { await recordFailure(client, operation, 'provider_outcome_unknown'); throw reconciliationError(operation.id); } + try { return outcome(await terminal(client, operation, true, isEmailUnavailable(error) ? 409 : 502, { error: 'Sign-in provider rejected the email change', message: 'Your email was not changed. Please start a new request to try again later or contact support.', operation_id: operation.id, reconciliation_required: false }, 'provider_rejected')); } + catch (localError) { if (localError instanceof EmailMutationError) throw localError; await recordFailure(client, operation, 'local_write_failed'); throw reconciliationError(operation.id); } + } + if (changed.id !== input.userId || changed.email.toLowerCase() !== email || changed.emailVerified !== true) { await recordFailure(client, operation, 'provider_response_mismatch'); throw reconciliationError(operation.id); } + let completed; + try { completed = await terminal(client, operation, false, 200, { status: 'primary_updated', primary_email: operation.new_email, operation_id: operation.id }, null); } + catch (error) { + if (error instanceof CommitUnknown) { await recordFailure(client, operation, 'local_commit_unknown'); throw reconciliationError(operation.id); } + await recordFailure(client, operation, 'local_write_failed'); + completed = await compensate(client, operation); + } + return outcome(completed); + }); +} diff --git a/server/src/types.ts b/server/src/types.ts index f9fef3edb8a..8ff1eba3646 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -393,6 +393,8 @@ export interface Impersonator { } export interface WorkOSUser { + /** Immutable primary-DB snapshot for this request only; never serialize or cache. */ + readonly authorizationSnapshot?: import('./db/user-authorization-snapshot-db.js').AuthorizationSnapshot; /** * The canonical workos_user_id for app-state queries. For singleton * identities this equals the authenticated WorkOS user. For non-primary diff --git a/server/src/utils/html-config.ts b/server/src/utils/html-config.ts index 7d0ad1bd5e2..0605d6f3b20 100644 --- a/server/src/utils/html-config.ts +++ b/server/src/utils/html-config.ts @@ -17,7 +17,7 @@ import { createLogger } from "../logger.js"; import { resolveWebUserAAOAdminAccess, } from "../addie/admin-status-lookup.js"; -import { isBreakGlassAdminEmail } from "../auth/admin-access.js"; +import { isBreakGlassAdmin } from "../auth/admin-access.js"; const logger = createLogger('html-config'); @@ -36,6 +36,7 @@ const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY || null; const POSTHOG_HOST = process.env.POSTHOG_HOST || 'https://us.i.posthog.com'; interface AppUser { + readonly authorizationSnapshot?: import("../db/user-authorization-snapshot-db.js").AuthorizationSnapshot; id?: string; email: string; firstName?: string | null; @@ -54,14 +55,13 @@ export function buildAppConfig(user?: AppUser | null): { posthog: { apiKey: string; host: string } | null; } { // Trust a pre-resolved isAdmin (set by enrichUserWithAdmin / dev-user flag). - // Fall back to ADMIN_EMAILS for callers that haven't enriched yet so we don't - // regress from prior behavior. + // The fallback requires authoritative credential and mutation state. let isAdmin = false; if (user) { if (typeof user.isAdmin === 'boolean') { isAdmin = user.isAdmin; } else { - isAdmin = isBreakGlassAdminEmail(user.email); + isAdmin = isBreakGlassAdmin(user.authorizationSnapshot); } } @@ -176,7 +176,7 @@ export async function enrichUserWithAdmin(user: AppUser | null | undefined): Pro if (user.id) { try { - user.isAdmin = (await resolveWebUserAAOAdminAccess(user.id, user.email)).isAdmin; + user.isAdmin = (await resolveWebUserAAOAdminAccess(user.id, user.authorizationSnapshot)).isAdmin; } catch (error) { logger.warn({ error, userId: user.id }, 'Failed to resolve isAdmin via working group; defaulting to false'); user.isAdmin = false; diff --git a/server/src/utils/resolve-user-org-authorization.ts b/server/src/utils/resolve-user-org-authorization.ts index 142b94b63ee..a0bc851c1f6 100644 --- a/server/src/utils/resolve-user-org-authorization.ts +++ b/server/src/utils/resolve-user-org-authorization.ts @@ -1,19 +1,24 @@ import type { WorkOS } from "@workos-inc/node"; import type { OrgAuthorizationPrincipal } from "../auth/organization-principal.js"; import { getOrganizationAuthorizationUserId } from "../auth/organization-principal.js"; -import { query } from "../db/client.js"; +import { + loadAuthorizationSnapshot, + sameAuthorizationIdentity, + sameAuthorizationSnapshot, + type AuthorizationSnapshot, +} from "../db/user-authorization-snapshot-db.js"; import { createLogger } from "../logger.js"; import { resolveUserRole } from "./resolve-user-role.js"; const logger = createLogger("resolve-user-org-authorization"); export type MembershipRole = "owner" | "admin" | "member"; -export type OrgAuthorizationSource = "workos" | "credential_grant"; +export type OrgAuthorizationSource = "workos" | "credential_grant" | "authorization_snapshot"; export interface UserOrgAuthorizationMembership { organizationId: string; role: MembershipRole; - source: OrgAuthorizationSource; + source: "workos" | "credential_grant"; } export type UserOrgAuthorizationResolution = @@ -52,7 +57,34 @@ export async function resolveUserOrgAuthorization( principal: OrgAuthorizationPrincipal, organizationId: string ): Promise { + const forbidden: UserOrgAuthorizationResolution = { + status: "forbidden", complete: true, unavailableSources: [], + }; + const unavailable = { + status: "unavailable", complete: false, unavailableSources: ["authorization_snapshot"], + } satisfies UserOrgAuthorizationResolution; + // Signing in alone does not select an organization or confer tenant access. + if (!organizationId?.trim()) return forbidden; const userId = getOrganizationAuthorizationUserId(principal); + let snapshot: AuthorizationSnapshot | null; + try { + snapshot = await loadAuthorizationSnapshot(userId, organizationId); + } catch (err) { + logger.warn({ err }, "Primary authorization snapshot unavailable"); + return unavailable; + } + if (!snapshot) return forbidden; + const previous = principal.authorizationSnapshot; + if (previous) { + // A sign-in with no org can bind an explicit route selection here. Once + // selected, a context belongs to that org and grant: replay cannot switch + // it or silently upgrade a revoked/replaced grant without a fresh request. + const current = previous.selectedOrganizationId === null + ? sameAuthorizationIdentity(previous, snapshot) + : sameAuthorizationSnapshot(previous, snapshot); + if (!current) return forbidden; + } + let directMembership: UserOrgAuthorizationMembership | null = null; let workosAvailable = false; @@ -64,7 +96,7 @@ export async function resolveUserOrgAuthorization( organizationId, }); const matchingMemberships = memberships.data.filter( - (membership) => membership.organizationId === organizationId + (membership) => membership.userId === userId && membership.organizationId === organizationId ); const activeRow = matchingMemberships.find( (membership) => membership.status === "active" @@ -88,32 +120,25 @@ export async function resolveUserOrgAuthorization( logger.warn("WorkOS client unavailable; checking explicit credential grant"); } - let grantAvailable = false; - let grantMembership: UserOrgAuthorizationMembership | null = null; + // WorkOS is an asynchronous authority source. Reject this decision if the + // persisted identity, epoch, selection or grant moved while it was in flight. + // In particular, never combine a pre-change provider decision with a new grant. try { - const grant = await query<{ workos_organization_id: string; role: string }>( - `SELECT workos_organization_id, role - FROM organization_credential_grants - WHERE workos_user_id = $1 - AND workos_organization_id = $2 - AND revoked_at IS NULL - AND effective_from <= NOW() - AND (effective_until IS NULL OR effective_until > NOW()) - LIMIT 1`, - [userId, organizationId] - ); - grantAvailable = true; - const row = grant.rows[0]; - if (row && VALID_ROLES.has(row.role)) { - grantMembership = { - organizationId: row.workos_organization_id, - role: row.role as MembershipRole, - source: "credential_grant", - }; + const current = await loadAuthorizationSnapshot(userId, organizationId); + if (!current || !sameAuthorizationSnapshot(snapshot, current)) { + return forbidden; } } catch (err) { - logger.warn({ err }, "Credential grant lookup failed"); + logger.warn({ err }, "Primary authorization snapshot revalidation unavailable"); + return unavailable; } + const grantMembership: UserOrgAuthorizationMembership | null = snapshot.credentialGrant + ? { + organizationId: snapshot.credentialGrant.organizationId, + role: snapshot.credentialGrant.role, + source: "credential_grant", + } + : null; let membership = directMembership; if ( @@ -126,7 +151,6 @@ export async function resolveUserOrgAuthorization( const unavailableSources: OrgAuthorizationSource[] = []; if (!workosAvailable) unavailableSources.push("workos"); - if (!grantAvailable) unavailableSources.push("credential_grant"); if (membership) { return { diff --git a/server/tests/integration/email-mutation-auth.test.ts b/server/tests/integration/email-mutation-auth.test.ts new file mode 100644 index 00000000000..88bf26e6144 --- /dev/null +++ b/server/tests/integration/email-mutation-auth.test.ts @@ -0,0 +1,341 @@ +/** Real request authorization must observe journal state even without an epoch bump. */ +import { createHmac, randomUUID } from 'node:crypto'; +import express, { type Express, type Request, type Response } from 'express'; +import supertest from 'supertest'; +import { generateKeyPair, SignJWT } from 'jose'; +import type { Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const providerMocks = vi.hoisted(() => ({ authenticate: vi.fn(), getUser: vi.fn(), updateUser: vi.fn() })); +vi.hoisted(() => { + process.env.DEV_USER_EMAIL = ''; + process.env.DEV_USER_ID = ''; + process.env.WORKOS_API_KEY = 'sk_email_mutation_test'; + process.env.WORKOS_CLIENT_ID = 'client_email_mutation_test'; + process.env.WORKOS_COOKIE_PASSWORD = 'email-mutation-cookie-password-32-bytes-minimum'; +}); +vi.mock('@workos-inc/node', () => ({ + WorkOS: vi.fn(function WorkOS() { + return { userManagement: { + loadSealedSession: ({ sessionData }: { sessionData: string }) => ({ + authenticate: () => providerMocks.authenticate(sessionData), + refresh: async () => ({ authenticated: false }), + }), + getUser: providerMocks.getUser, + updateUser: providerMocks.updateUser, + } }; + }), +})); +// Isolate configuration email authority from unrelated group and ban decisions. +vi.mock('../../src/addie/mcp/admin-tools.js', () => ({ isWebUserAAOAdmin: async () => false })); +vi.mock('../../src/db/bans-db.js', () => ({ bansDb: { checkPlatformBan: async () => ({ banned: false }) } })); + +const USER_ID = 'user_email_mutation_auth'; +const OTHER_USER_ID = 'user_email_mutation_auth_conflict'; +const ADMIN_EMAIL = 'email-mutation-admin@pinnacle.example'; +const ORDINARY_EMAIL = 'email-mutation-member@pinnacle.example'; +const oldAdminEmails = process.env.ADMIN_EMAILS; +type AuthModule = typeof import('../../src/middleware/auth.js'); +type DatabaseModule = typeof import('../../src/db/client.js'); +type JwtModule = typeof import('../../src/auth/workos-jwt.js'); +type MutationModule = typeof import('../../src/services/email-mutation.js'); +type Replica = { + app: Express; auth: AuthModule; database: DatabaseModule; jwt: JwtModule; pool: Pool; + cookieFixtures: Map>; +}; +type Authentication = 'cookie' | 'bearer'; + +describe('member email mutation authorization across independent replicas', () => { + let replicaA: Replica; + let replicaB: Replica; + let mutations: MutationModule; + let signingKeys: Awaited>; + let provider: { id: string; email: string; emailVerified: boolean }; + const cookieClaims = new Map(); + const extraReplicas: Replica[] = []; + let fixtureIdentities: string[] = []; + const connectionString = process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test'; + + async function createReplica(): Promise { + // Reset the module registry, retaining both imported instances. Each has its + // own provider caches and primary pool, like independent application nodes. + vi.resetModules(); + const database = await import('../../src/db/client.js'); + const pool = database.initializeDatabase({ connectionString }); + const jwt = await import('../../src/auth/workos-jwt.js'); + jwt.__setJWKSForTesting(async () => signingKeys.publicKey); + const auth = await import('../../src/middleware/auth.js'); + const { csrfProtection } = await import('../../src/middleware/csrf.js'); + auth.stopAuthTimers(); + const app = express(); + const cookieFixtures = new Map>(); + // Match the authorization-epoch HTTP suite: supply app-owned parsed cookie + // fixtures while requests still send the corresponding actual Cookie header. + // Absent cookies stay absent; a presented empty cookie retains its value. + app.use((req, _res, next) => { + const header = req.get('Cookie'); + if (header !== undefined) expect(cookieFixtures.has(header)).toBe(true); + req.cookies = header === undefined ? {} : { ...cookieFixtures.get(header) }; + next(); + }); + app.use(csrfProtection); + const respond = (req: Request, res: Response) => res.json({ + user_id: req.user?.id, + authenticated_user_id: req.user?.authorizationSnapshot?.authenticatedUserId, + email: req.user?.email, + verified: req.user?.emailVerified, + epoch: req.user?.authorizationSnapshot?.authorizationEpoch, + pending: req.user?.authorizationSnapshot?.credential.emailMutationPending, + mechanism: req.adminAccessMechanism, + }); + app.get('/api/admin/check', auth.requireAuth, auth.requireAdmin, respond); + app.get('/api/member/check', auth.requireAuth, respond); + return { app, auth, database, jwt, pool, cookieFixtures }; + } + async function token(authentication: Authentication, email = ADMIN_EMAIL, verified = true): Promise { + if (authentication === 'cookie') { + const sealed = `sealed-email-${randomUUID()}`; + cookieClaims.set(sealed, { email, emailVerified: verified }); + return sealed; + } + // JWT signature verification remains real; only the remote JWKS transport + // is replaced by a local public key, separately on each replica. + return new SignJWT({ email, azp: process.env.WORKOS_CLIENT_ID }) + .setProtectedHeader({ alg: 'RS256' }).setSubject(USER_ID).setJti(randomUUID()) + .setIssuedAt().setExpirationTime('1h').sign(signingKeys.privateKey); + } + function request(replica: Replica, authentication: Authentication, credential: string, admin = true) { + const req = supertest(replica.app).get(admin ? '/api/admin/check' : '/api/member/check').set('Accept', 'application/json'); + if (authentication === 'cookie') { + const header = `wos-session=${credential}`; + replica.cookieFixtures.set(header, { 'wos-session': credential }); + return req.set('Cookie', header); + } + return req.set('Authorization', `Bearer ${credential}`); + } + async function epoch() { + return (await replicaB.pool.query('SELECT COALESCE((SELECT epoch FROM authorization_epochs WHERE workos_user_id=$1),0)::text AS epoch', [USER_ID])).rows[0].epoch; + } + async function journal() { + return (await replicaB.pool.query('SELECT * FROM email_mutations WHERE workos_user_id=$1', [USER_ID])).rows; + } + const mutate = (operationId = randomUUID()) => mutations.setPrimaryEmail({ userId: USER_ID, email: ORDINARY_EMAIL, operationId }); + async function cleanup() { + await replicaB.pool.query('DELETE FROM email_mutations WHERE workos_user_id=ANY($1)', [[USER_ID, OTHER_USER_ID]]); + await replicaB.pool.query('DELETE FROM person_relationships WHERE workos_user_id=ANY($1)', [[USER_ID, OTHER_USER_ID]]); + const bindings = await replicaB.pool.query('SELECT identity_id FROM identity_workos_users WHERE workos_user_id=ANY($1)', [[USER_ID, OTHER_USER_ID]]); + await replicaB.pool.query('DELETE FROM users WHERE workos_user_id=ANY($1)', [[USER_ID, OTHER_USER_ID]]); + await replicaB.pool.query('DELETE FROM identities WHERE id=ANY($1)', [[...fixtureIdentities, ...bindings.rows.map(row => row.identity_id)]]); + fixtureIdentities = []; + } + beforeAll(async () => { + process.env.ADMIN_EMAILS = ADMIN_EMAIL; + signingKeys = await generateKeyPair('RS256'); + replicaA = await createReplica(); + const { runMigrations } = await import('../../src/db/migrate.js'); + await runMigrations(); + replicaB = await createReplica(); + mutations = await import('../../src/services/email-mutation.js'); + expect(replicaA.auth.requireAuth).not.toBe(replicaB.auth.requireAuth); + expect(replicaA.pool).not.toBe(replicaB.pool); + }, 60_000); + beforeEach(async () => { + vi.restoreAllMocks(); + await cleanup(); + replicaA.auth.invalidateSessionsForUsers([USER_ID]); + replicaB.auth.invalidateSessionsForUsers([USER_ID]); + replicaA.cookieFixtures.clear(); + replicaB.cookieFixtures.clear(); + replicaA.jwt.__setJWKSForTesting(async () => signingKeys.publicKey); + replicaB.jwt.__setJWKSForTesting(async () => signingKeys.publicKey); + cookieClaims.clear(); + await replicaB.pool.query('INSERT INTO users(workos_user_id,email,email_verified,workos_created_at,workos_updated_at) VALUES($1,$2,true,NOW(),NOW())', [USER_ID, ADMIN_EMAIL]); + await replicaB.pool.query('INSERT INTO user_email_aliases(workos_user_id,email) VALUES($1,$2)', [USER_ID, ORDINARY_EMAIL]); + fixtureIdentities = (await replicaB.pool.query('SELECT identity_id FROM identity_workos_users WHERE workos_user_id=$1', [USER_ID])).rows.map(row => row.identity_id); + provider = { id: USER_ID, email: ADMIN_EMAIL, emailVerified: true }; + providerMocks.authenticate.mockReset().mockImplementation(async (sealed: string) => { + const claims = cookieClaims.get(sealed); + return claims ? { + authenticated: true, user: { id: USER_ID, ...claims, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }, accessToken: 'test-access-token', + } : { authenticated: false }; + }); + providerMocks.getUser.mockReset().mockImplementation(async () => ({ ...provider })); + providerMocks.updateUser.mockReset().mockImplementation(async ({ userId, email, emailVerified }) => { + provider = { id: userId, email, emailVerified }; return { ...provider }; + }); + }); + afterAll(async () => { + vi.restoreAllMocks(); + if (replicaB) await cleanup(); + for (const replica of [replicaA, replicaB, ...extraReplicas].filter(Boolean)) { + replica.auth.stopAuthTimers(); + await replica.database.closeDatabase(); + } + if (oldAdminEmails === undefined) delete process.env.ADMIN_EMAILS; + else process.env.ADMIN_EMAILS = oldAdminEmails; + }); + + it('rejects absent credentials without reusing a previous request cookie fixture', async () => { + const cookie = await token('cookie'); + for (const replica of [replicaA, replicaB]) { + const authenticated = await request(replica, 'cookie', cookie); + expect(authenticated.status).toBe(200); + expect(authenticated.headers['set-cookie']).toEqual(expect.arrayContaining([expect.stringMatching(/^csrf-token=/)])); + const response = await supertest(replica.app).get('/api/member/check').set('Accept', 'application/json'); + expect(response.status).toBe(401); + expect(response.body).not.toHaveProperty('user_id'); + } + }); + it.each(['', 'invalid-sealed-cookie'])('rejects the presented cookie %j through real authentication', async credential => { + for (const replica of [replicaA, replicaB]) { + const response = await request(replica, 'cookie', credential, false); + expect(response.status).toBe(401); + expect(response.body).not.toHaveProperty('user_id'); + } + if (credential === '') expect(providerMocks.authenticate).not.toHaveBeenCalled(); + else expect(providerMocks.authenticate.mock.calls).toEqual([[credential], [credential]]); + }); + it.each(['', 'Basic invalid'])('rejects malformed presented Authorization %j without a cookie fixture', async authorization => { + for (const replica of [replicaA, replicaB]) { + const response = await supertest(replica.app).get('/api/member/check') + .set('Accept', 'application/json').set('Authorization', authorization); + expect(response.status).toBe(401); + expect(response.body).not.toHaveProperty('user_id'); + } + expect(providerMocks.authenticate).not.toHaveBeenCalled(); + }); + it('rejects a presented JWT signed by a different key without using a previous cookie fixture', async () => { + const wrongKeys = await generateKeyPair('RS256'); + const invalidJwt = await new SignJWT({ email: ADMIN_EMAIL, azp: process.env.WORKOS_CLIENT_ID }) + .setProtectedHeader({ alg: 'RS256' }).setSubject(USER_ID).setJti(randomUUID()) + .setIssuedAt().setExpirationTime('1h').sign(wrongKeys.privateKey); + const cookie = await token('cookie'); + for (const replica of [replicaA, replicaB]) { + expect((await request(replica, 'cookie', cookie)).status).toBe(200); + providerMocks.authenticate.mockClear(); + const response = await request(replica, 'bearer', invalidJwt, false); + expect(response.status).toBe(401); + expect(response.body).not.toHaveProperty('user_id'); + expect(providerMocks.authenticate).not.toHaveBeenCalled(); + } + }); + + describe.each(['cookie', 'bearer'] as const)('%s authentication', authentication => { + it('revokes old admin claims after a successful email change on another replica, including cold recaching', async () => { + const oldToken = await token(authentication); + expect((await request(replicaA, authentication, oldToken)).status).toBe(200); + await mutate(); + expect(await epoch()).toBe('1'); + for (const replica of [replicaA, replicaB]) { + for (const credential of [oldToken, await token(authentication)]) { + expect((await request(replica, authentication, credential)).status).toBe(403); + expect((await request(replica, authentication, credential, false)).body).toMatchObject({ email: ORDINARY_EMAIL, verified: true, epoch: '1', pending: false }); + } + } + expect(providerMocks.updateUser).toHaveBeenCalledOnce(); + }); + it.each([USER_ID, OTHER_USER_ID])('denies pending provider-success crash authority with primary identity %s and unchanged epoch', async primaryId => { + await replicaB.pool.query('INSERT INTO users(workos_user_id,email,email_verified,workos_created_at,workos_updated_at) VALUES($1,$2,true,NOW(),NOW())', [OTHER_USER_ID, 'unrelated-email-auth@pinnacle.example']); + fixtureIdentities = (await replicaB.pool.query('SELECT identity_id FROM identity_workos_users WHERE workos_user_id=ANY($1)', [[USER_ID, OTHER_USER_ID]])).rows.map(row => row.identity_id); + const secondaryId = primaryId === USER_ID ? OTHER_USER_ID : USER_ID; + await replicaB.pool.query('UPDATE identity_workos_users SET identity_id=(SELECT identity_id FROM identity_workos_users WHERE workos_user_id=$1),is_primary=false WHERE workos_user_id=$2', [primaryId, secondaryId]); + const oldToken = await token(authentication); + const positiveControl = await request(replicaA, authentication, oldToken); + expect(positiveControl.status).toBe(200); + expect(positiveControl.body).toMatchObject({ user_id: primaryId, authenticated_user_id: USER_ID, pending: false }); + // Exact durable state left by a process exit after WorkOS success but + // before local terminal/recordFailure transactions. No recovery is run. + const id = randomUUID(); + await replicaB.pool.query(`INSERT INTO email_mutations + (id,workos_user_id,actor_user_id,payload_hash,old_email,old_email_verified,new_email,expected_email_version,state,result_status,result_body) + VALUES($1,$2,$2,$3,$4,true,$5,0,'pending',409,$6)`, [id, USER_ID, + createHmac('sha256', 'adcp:member-primary-email:idempotency:v1').update(JSON.stringify([USER_ID, ORDINARY_EMAIL])).digest('hex'), + ADMIN_EMAIL, ORDINARY_EMAIL, { operation_id: id, reconciliation_required: true }]); + await providerMocks.updateUser({ userId: USER_ID, email: ORDINARY_EMAIL, emailVerified: true }); + expect(await epoch()).toBe('0'); + expect((await journal())[0]).toMatchObject({ state: 'pending', epoch_after: null, applied_email_version: null }); + for (const replica of [replicaA, replicaB]) { + for (const credential of [oldToken, await token(authentication)]) { + expect((await request(replica, authentication, credential)).status).toBe(403); + expect((await request(replica, authentication, credential, false)).body).toMatchObject({ email: ADMIN_EMAIL, epoch: '0', pending: true }); + } + } + expect(providerMocks.getUser).not.toHaveBeenCalled(); + expect(providerMocks.updateUser).toHaveBeenCalledOnce(); + }); + it('denies reconciliation authority after an ambiguous provider write, including delayed completion', async () => { + const oldToken = await token(authentication); + expect((await request(replicaA, authentication, oldToken)).status).toBe(200); + providerMocks.updateUser.mockImplementationOnce(async () => { throw Object.assign(new Error('provider timeout'), { status: 408 }); }); + await expect(mutate()).rejects.toMatchObject({ body: { reconciliation_required: true } }); + // Completion of the original provider request cannot clear the journal. + provider = { id: USER_ID, email: ORDINARY_EMAIL, emailVerified: true }; + expect((await journal())[0]).toMatchObject({ state: 'reconciliation_required', epoch_after: '1' }); + for (const replica of [replicaA, replicaB]) { + for (const credential of [oldToken, await token(authentication)]) { + expect((await request(replica, authentication, credential)).status).toBe(403); + } + } + expect(providerMocks.updateUser).toHaveBeenCalledOnce(); + }); + it('restores verified local admin authority only after known provider compensation commits', async () => { + const oldToken = await token(authentication); + expect((await request(replicaA, authentication, oldToken)).status).toBe(200); + await replicaB.pool.query('INSERT INTO person_relationships(workos_user_id,email) VALUES($1,$2)', [USER_ID, ADMIN_EMAIL]); + await replicaB.pool.query('INSERT INTO person_relationships(workos_user_id,email) VALUES($1,$2)', [OTHER_USER_ID, ORDINARY_EMAIL]); + await expect(mutate()).rejects.toMatchObject({ body: { reconciliation_required: false } }); + expect((await journal())[0]).toMatchObject({ state: 'compensated', epoch_after: '2' }); + expect(provider).toEqual({ id: USER_ID, email: ADMIN_EMAIL, emailVerified: true }); + for (const replica of [replicaA, replicaB]) { + for (const credential of [oldToken, await token(authentication, ORDINARY_EMAIL)]) { + const response = await request(replica, authentication, credential); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ email: ADMIN_EMAIL, verified: true, epoch: '2', pending: false, mechanism: 'break_glass_admin_email' }); + } + } + expect(providerMocks.updateUser).toHaveBeenCalledTimes(2); + }); + it('keeps baseline read outages reconciliation-required and blocks old admin claims on every replica', async () => { + const oldToken = await token(authentication); + expect((await request(replicaA, authentication, oldToken)).status).toBe(200); + providerMocks.getUser.mockRejectedValueOnce(new Error('private provider diagnostic')); + const id = randomUUID(); + await expect(mutate(id)).rejects.toMatchObject({ status: 409, body: { reconciliation_required: true } }); + await expect(mutate(id)).rejects.toMatchObject({ status: 409, body: { reconciliation_required: true } }); + expect((await journal())[0]).toMatchObject({ state: 'reconciliation_required', failure_code: 'provider_read_failed', applied_email_version: null }); + for (const replica of [replicaA, replicaB]) { + expect((await request(replica, authentication, oldToken)).status).toBe(403); + expect((await request(replica, authentication, await token(authentication))).status).toBe(403); + } + expect(providerMocks.getUser).toHaveBeenCalledOnce(); + expect(providerMocks.updateUser).not.toHaveBeenCalled(); + }); + it('fails closed on a primary snapshot outage for warm and cold provider credentials', async () => { + const unavailable = await createReplica(); + extraReplicas.push(unavailable); + const oldToken = await token(authentication); + expect((await request(unavailable, authentication, oldToken)).status).toBe(200); + // Close only this replica's real database pool; cached provider proof + // remains present and cannot substitute for its unavailable authority DB. + await unavailable.database.closeDatabase(); + for (const credential of [oldToken, await token(authentication)]) { + const response = await request(unavailable, authentication, credential); + expect(response.status).toBe(503); + expect(response.body).toEqual({ error: 'Authorization service temporarily unavailable' }); + } + }); + it('cannot recover old admin authority through a provider authentication outage after local success', async () => { + const oldToken = await token(authentication); + expect((await request(replicaA, authentication, oldToken)).status).toBe(200); + await mutate(); + if (authentication === 'cookie') providerMocks.authenticate.mockRejectedValue(Object.assign(new Error('provider unavailable'), { status: 503 })); + else for (const replica of [replicaA, replicaB]) replica.jwt.__setJWKSForTesting(async () => { throw Object.assign(new Error('provider unavailable'), { status: 503 }); }); + expect((await request(replicaA, authentication, oldToken)).status).toBe(403); + for (const replica of [replicaA, replicaB]) { + const response = await request(replica, authentication, await token(authentication)); + expect(response.status).toBe(503); + expect(response.body).not.toHaveProperty('mechanism'); + } + }); + }); +}); diff --git a/server/tests/integration/email-mutation.test.ts b/server/tests/integration/email-mutation.test.ts new file mode 100644 index 00000000000..b8ec75fca4c --- /dev/null +++ b/server/tests/integration/email-mutation.test.ts @@ -0,0 +1,358 @@ +import { createHmac, randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Client, type Pool } from 'pg'; +import { initializeDatabase, closeDatabase } from '../../src/db/client.js'; +import * as databaseClient from '../../src/db/client.js'; +import { runMigrations } from '../../src/db/migrate.js'; +const { getUser, updateUser, invalidate } = vi.hoisted(() => ({ getUser: vi.fn(), updateUser: vi.fn(), invalidate: vi.fn() })); +vi.mock('../../src/auth/workos-client.js', () => ({ getEmailMutationWorkos: () => ({ userManagement: { getUser, updateUser } }) })); +vi.mock('../../src/middleware/auth.js', () => ({ invalidateSessionsForUsers: invalidate })); +import { setPrimaryEmail } from '../../src/services/email-mutation.js'; +const userId = 'user_email_mutation_test'; +const otherUserId = 'user_email_mutation_other'; +const oldEmail = 'email-mutation-old@test.example'; +const newEmail = 'email-mutation-new@test.example'; +const thirdEmail = 'email-mutation-third@test.example'; + +describe('durable exact-credential email mutation', () => { + let pool: Pool; + let provider: { id: string; email: string; emailVerified: boolean }; + const mutate = (operationId = randomUUID(), email = newEmail) => setPrimaryEmail({ userId, email, operationId }); + const journal = async () => (await pool.query('SELECT * FROM email_mutations WHERE workos_user_id=$1 ORDER BY created_at', [userId])).rows; + const epoch = async () => Number((await pool.query('SELECT epoch FROM authorization_epochs WHERE workos_user_id=$1', [userId])).rows[0]?.epoch ?? 0); + async function state() { + return { + user: (await pool.query('SELECT email,email_verified FROM users WHERE workos_user_id=$1', [userId])).rows, + aliases: (await pool.query('SELECT email,verified_at IS NOT NULL AS verified FROM user_email_aliases WHERE workos_user_id=$1 ORDER BY email', [userId])).rows, + memberships: (await pool.query('SELECT workos_user_id,workos_organization_id,workos_membership_id,role,email FROM organization_memberships WHERE workos_user_id=$1', [userId])).rows, + people: (await pool.query('SELECT email FROM person_relationships WHERE workos_user_id=$1', [userId])).rows, + }; + } + async function clearFault() { + await pool.query('DROP FUNCTION IF EXISTS test_email_mutation_fault() CASCADE'); + await pool.query('DROP SEQUENCE IF EXISTS test_email_mutation_fault_counter'); + } + async function fault(table: string, event: string, body: string, timing = 'BEFORE') { + await pool.query(`CREATE FUNCTION test_email_mutation_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN ${body} RETURN NEW; END $$`); + await pool.query(`CREATE TRIGGER zz_test_email_mutation_fault ${timing} ${event} ON ${table} FOR EACH ROW EXECUTE FUNCTION test_email_mutation_fault()`); + } + async function cleanup() { + await clearFault(); + await pool.query('DELETE FROM email_mutations WHERE workos_user_id=ANY($1)', [[userId, otherUserId]]); + await pool.query('DELETE FROM person_relationships WHERE workos_user_id=ANY($1)', [[userId, otherUserId]]); + await pool.query('DELETE FROM organization_memberships WHERE workos_user_id=$1', [userId]); + await pool.query('DELETE FROM users WHERE workos_user_id=ANY($1)', [[userId, otherUserId]]); + } + beforeAll(async () => { + pool = initializeDatabase({ connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test' }); + await runMigrations(); + }, 60_000); + beforeEach(async () => { + vi.restoreAllMocks(); + await cleanup(); + await pool.query('INSERT INTO users(workos_user_id,email,email_verified,workos_created_at,workos_updated_at) VALUES($1,$2,false,NOW(),NOW())', [userId, oldEmail]); + await pool.query('INSERT INTO user_email_aliases(workos_user_id,email) VALUES($1,$2)', [userId, newEmail]); + await pool.query("INSERT INTO organization_memberships(workos_user_id,workos_organization_id,workos_membership_id,email,role) VALUES($1,'org_email_mutation_test','om_email_mutation_test',$2,'owner')", [userId, oldEmail]); + await pool.query('INSERT INTO person_relationships(workos_user_id,email) VALUES($1,$2)', [userId, oldEmail]); + provider = { id: userId, email: oldEmail, emailVerified: false }; + getUser.mockReset().mockImplementation(async () => ({ ...provider })); + updateUser.mockReset().mockImplementation(async ({ email, emailVerified }) => { provider = { id: userId, email, emailVerified }; return { ...provider }; }); + invalidate.mockReset(); + }); + afterAll(async () => { vi.restoreAllMocks(); await cleanup(); await closeDatabase(); }); + + it('atomically commits aliases, denormalizations and exact credential epoch before invalidating sessions', async () => { + const before = await state(); + const id = randomUUID(); + expect(await mutate(id)).toEqual({ status: 'primary_updated', primary_email: newEmail, operation_id: id }); + const after = await state(); + expect(after.user).toEqual([{ email: newEmail, email_verified: true }]); + expect(after.aliases).toEqual([{ email: oldEmail, verified: false }]); + expect(after.memberships).toEqual(before.memberships.map(row => ({ ...row, email: newEmail }))); + expect(after.people).toEqual([{ email: newEmail }]); + expect(await epoch()).toBe(1); + expect(invalidate).toHaveBeenCalledExactlyOnceWith([userId]); + expect((await journal())[0]).toMatchObject({ state: 'succeeded', epoch_after: '1', applied_email_version: '1', result_status: 200 }); + expect((await journal())[0].payload_hash).toBe('01e362c4211926d68027f1a1124c941eead66480c7accd5e53126cc9dc31c3da'); + }); + it('replays the same normalized payload and original result after a later different operation without provider retry', async () => { + const id = randomUUID(); + const first = await mutate(id); + await pool.query('INSERT INTO user_email_aliases(workos_user_id,email) VALUES($1,$2)', [userId, thirdEmail]); + await mutate(randomUUID(), thirdEmail); + expect(await mutate(id, newEmail.toUpperCase())).toEqual(first); + expect(getUser).toHaveBeenCalledTimes(2); + expect(updateUser).toHaveBeenCalledTimes(2); + expect(provider.email).toBe(thirdEmail); + await mutate(randomUUID(), newEmail); + const records = await journal(); + expect(records[0].payload_hash).toBe(records[2].payload_hash); + expect(records[0].payload_hash).not.toBe(records[1].payload_hash); + }); + it('uses a different durable request fingerprint for the same email on another credential', async () => { + await mutate(); + await pool.query('INSERT INTO users(workos_user_id,email,email_verified,workos_created_at,workos_updated_at) VALUES($1,$2,true,NOW(),NOW())', [otherUserId, thirdEmail]); + await pool.query('INSERT INTO user_email_aliases(workos_user_id,email) VALUES($1,$2)', [otherUserId, newEmail]); + getUser.mockRejectedValueOnce(new Error('provider unavailable')); + await expect(setPrimaryEmail({ userId: otherUserId, email: newEmail, operationId: randomUUID() })).rejects.toMatchObject({ body: { reconciliation_required: true } }); + const other = await pool.query('SELECT payload_hash FROM email_mutations WHERE workos_user_id=$1', [otherUserId]); + expect(other.rows[0].payload_hash).toMatch(/^[a-f0-9]{64}$/); + expect(other.rows[0].payload_hash).not.toBe((await journal())[0].payload_hash); + }); + it('rejects reuse with different payload or credential without replaying private results', async () => { + const id = randomUUID(); await mutate(id); + await expect(mutate(id, thirdEmail)).rejects.toMatchObject({ status: 409, body: { error: 'operation_id_reused' } }); + const error = await setPrimaryEmail({ userId: otherUserId, operationId: id, email: newEmail }).catch(error => error); + expect(error.body).toEqual({ error: 'operation_id_reused', message: 'This request identifier belongs to a different email change.' }); + expect(updateUser).toHaveBeenCalledOnce(); + }); + it('returns a conflict for concurrent cross-credential UUID reuse after both initial lookups', async () => { + const otherEmail = 'other-base@test.example'; + await pool.query('INSERT INTO users(workos_user_id,email,email_verified,workos_created_at,workos_updated_at) VALUES($1,$2,true,NOW(),NOW())', [otherUserId, otherEmail]); + await pool.query('INSERT INTO user_email_aliases(workos_user_id,email) VALUES($1,$2)', [otherUserId, thirdEmail]); + const providers = new Map([[userId, { ...provider }], [otherUserId, { id: otherUserId, email: otherEmail, emailVerified: true }]]); + getUser.mockImplementation(async id => ({ ...providers.get(id)! })); + updateUser.mockImplementation(async ({ userId: id, email, emailVerified }) => { + const changed = { id, email, emailVerified }; providers.set(id, changed); return changed; + }); + const id = randomUUID(); + let release!: () => void; + const overlap = new Promise(resolve => { release = resolve; }); + let absentReads = 0; + const query = Client.prototype.query; + vi.spyOn(Client.prototype, 'query').mockImplementation(function (this: Client, ...args: any[]) { + const result = (query as any).apply(this, args); + if (args[0] === 'SELECT * FROM email_mutations WHERE id = $1' && args[1]?.[0] === id && absentReads < 2) { + return Promise.resolve(result).then(async response => { + expect(response.rowCount).toBe(0); + absentReads += 1; + if (absentReads === 2) release(); + await overlap; + return response; + }); + } + return result; + }); + const results = await Promise.allSettled([mutate(id), setPrimaryEmail({ userId: otherUserId, email: thirdEmail, operationId: id })]); + expect(absentReads).toBe(2); + expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1); + const rejected = results.find(result => result.status === 'rejected') as PromiseRejectedResult; + expect(rejected.reason).toMatchObject({ status: 409, body: { error: 'operation_id_reused' } }); + expect(getUser).toHaveBeenCalledOnce(); expect(updateUser).toHaveBeenCalledOnce(); + }); + it('confirms durable intent before provider reads and writes', async () => { + getUser.mockImplementationOnce(async () => { expect((await journal())[0].state).toBe('pending'); return { ...provider }; }); + updateUser.mockImplementationOnce(async ({ email, emailVerified }) => { expect((await journal())[0].state).toBe('pending'); provider = { id: userId, email, emailVerified }; return provider; }); + await mutate(); + }); + it('validates verified ownership and rejects a new operation for the current primary before provider work', async () => { + await expect(mutate(randomUUID(), 'unlinked@test.example')).rejects.toMatchObject({ status: 404 }); + await expect(mutate(randomUUID(), oldEmail)).rejects.toMatchObject({ status: 400 }); + expect(getUser).not.toHaveBeenCalled(); expect(updateUser).not.toHaveBeenCalled(); expect(await journal()).toEqual([]); + }); + it('stores and replays a provider rejection with explicit retryable terminal state and no secret leakage', async () => { + const before = await state(); const id = randomUUID(); + updateUser.mockRejectedValueOnce(Object.assign(new Error('secret provider diagnostic'), { status: 422 })); + const first = await mutate(id).catch(error => error); + const second = await mutate(id).catch(error => error); + expect(second.body).toEqual(first.body); expect(second.status).toBe(first.status); + expect(first.body.reconciliation_required).toBe(false); + expect(JSON.stringify(first.body)).not.toContain('secret'); + expect(await state()).toEqual(before); expect(await epoch()).toBe(1); + expect(getUser).toHaveBeenCalledOnce(); expect(updateUser).toHaveBeenCalledOnce(); + }); + it('keeps an unreadable provider baseline unresolved on every retry without claiming compensation', async () => { + const before = await state(); const id = randomUUID(); + getUser.mockRejectedValueOnce(new Error('secret provider diagnostic')); + const first = await mutate(id).catch(error => error); + const second = await mutate(id).catch(error => error); + expect(first).toMatchObject({ status: 409, body: { reconciliation_required: true, operation_id: id } }); + expect(second.body).toEqual(first.body); expect(second.status).toBe(first.status); + expect(JSON.stringify(first.body)).not.toContain('secret'); + expect(await state()).toEqual(before); expect(await epoch()).toBe(1); + expect((await journal())[0]).toMatchObject({ state: 'reconciliation_required', failure_code: 'provider_read_failed', applied_email_version: null, epoch_after: '1' }); + expect(getUser).toHaveBeenCalledOnce(); expect(updateUser).not.toHaveBeenCalled(); + }); + it('rolls back writes and compensates provider state, while bumping epochs for reconciliation and compensation', async () => { + await pool.query('INSERT INTO person_relationships(workos_user_id,email) VALUES($1,$2)', [otherUserId, newEmail]); + const before = await state(); + await expect(mutate()).rejects.toMatchObject({ status: 503, body: { reconciliation_required: false } }); + expect(provider).toEqual({ id: userId, email: oldEmail, emailVerified: false }); + expect(await state()).toEqual(before); + expect(updateUser).toHaveBeenNthCalledWith(2, { userId, email: oldEmail, emailVerified: false }); + expect(await epoch()).toBe(2); + expect((await journal())[0]).toMatchObject({ state: 'compensated', epoch_after: '2' }); + }); + it('keeps compensation failure permanently blocked with no exported support mutation bypass', async () => { + await pool.query('INSERT INTO person_relationships(workos_user_id,email) VALUES($1,$2)', [otherUserId, newEmail]); + updateUser.mockImplementationOnce(async ({ email, emailVerified }) => { provider = { id: userId, email, emailVerified }; return provider; }).mockRejectedValueOnce(new Error('timeout')); + const id = randomUUID(); await expect(mutate(id)).rejects.toMatchObject({ body: { reconciliation_required: true } }); + await expect(mutate(id)).rejects.toMatchObject({ body: { reconciliation_required: true } }); + await expect(mutate()).rejects.toMatchObject({ body: { reconciliation_required: true } }); + expect(updateUser).toHaveBeenCalledTimes(2); + expect((await import('../../src/services/email-mutation.js'))).not.toHaveProperty('reconcileEmailMutation'); + }); + it('blocks timed-out writes even when GET is old and the original request completes later', async () => { + let complete!: () => void; + updateUser.mockImplementationOnce(async ({ email, emailVerified }) => { complete = () => { provider = { id: userId, email, emailVerified }; }; throw Object.assign(new Error('secret timeout'), { status: 408 }); }); + const id = randomUUID(); await expect(mutate(id)).rejects.toMatchObject({ body: { reconciliation_required: true } }); + expect((await getUser()).email).toBe(oldEmail); complete(); expect((await getUser()).email).toBe(newEmail); + await expect(mutate(id)).rejects.toMatchObject({ body: { reconciliation_required: true } }); + expect(updateUser).toHaveBeenCalledOnce(); expect(await epoch()).toBe(1); + }); + it('reconciles a crashed pending intent with one epoch bump and never retries the provider', async () => { + const id = randomUUID(); + const body = { operation_id: id, error: 'Email reconciliation required', message: 'Support required', reconciliation_required: true }; + await pool.query(`INSERT INTO email_mutations(id,workos_user_id,actor_user_id,payload_hash,old_email,old_email_verified,new_email,expected_email_version,state,result_status,result_body) + VALUES($1,$2,$2,$3,$4,false,$5,0,'pending',409,$6)`, [id,userId,createHmac('sha256', 'adcp:member-primary-email:idempotency:v1').update(JSON.stringify([userId,newEmail])).digest('hex'),oldEmail,newEmail,body]); + await expect(mutate(id)).rejects.toMatchObject({ status: 409, body }); + await expect(mutate(id)).rejects.toMatchObject({ status: 409, body }); + expect(getUser).not.toHaveBeenCalled(); expect(updateUser).not.toHaveBeenCalled(); expect(await epoch()).toBe(1); + }); + it('reports contention as retryable busy without a nonexistent reconciliation operation', async () => { + const client = await pool.connect(); + try { + await client.query('SELECT pg_advisory_lock(hashtextextended($1,6827))', [userId]); + const error = await mutate().catch(error => error); + expect(error.body).toEqual({ error: 'credential_busy', message: 'An email change is already in progress. Please retry.', retryable: true }); + expect(await journal()).toEqual([]); + } finally { await client.query('SELECT pg_advisory_unlock(hashtextextended($1,6827))', [userId]); client.release(); } + }); + it.each(['null', 'after-delete'])('does not call the provider after an intent INSERT %s fault', async kind => { + if (kind === 'null') await fault('email_mutations', 'INSERT', 'RETURN NULL;'); + else await fault('email_mutations', 'INSERT', 'DELETE FROM email_mutations WHERE id=NEW.id;', 'AFTER'); + const id = randomUUID(); await expect(mutate(id)).rejects.toMatchObject({ status: 503 }); + expect(getUser).not.toHaveBeenCalled(); expect(updateUser).not.toHaveBeenCalled(); expect(await journal()).toEqual([]); + await clearFault(); await mutate(id); expect(updateUser).toHaveBeenCalledOnce(); + }); + it.each(['null', 'after-delete'])('cannot claim a terminal outcome after a journal UPDATE %s fault', async kind => { + if (kind === 'null') await fault('email_mutations', 'UPDATE', "IF NEW.state IN ('succeeded','compensated') THEN RETURN NULL; END IF;"); + else await fault('email_mutations', 'UPDATE', "IF NEW.state IN ('succeeded','compensated') THEN DELETE FROM email_mutations WHERE id=NEW.id; END IF;", 'AFTER'); + const before = await state(); await expect(mutate()).rejects.toMatchObject({ body: { reconciliation_required: true } }); + expect(await state()).toEqual(before); expect(provider.email).toBe(oldEmail); + expect((await journal())[0].state).toBe('reconciliation_required'); + }); + it('leaves the durable pending intent when reconciliation journal UPDATE is skipped', async () => { + await fault('email_mutations', 'UPDATE', "IF NEW.state='reconciliation_required' THEN RETURN NULL; END IF;"); + updateUser.mockRejectedValueOnce(new Error('timeout')); + const id = randomUUID(); await expect(mutate(id)).rejects.toMatchObject({ body: { reconciliation_required: true } }); + expect((await journal())[0].state).toBe('pending'); expect(await epoch()).toBe(0); expect(invalidate).not.toHaveBeenCalled(); + await expect(mutate(id)).rejects.toMatchObject({ body: { reconciliation_required: true } }); expect(updateUser).toHaveBeenCalledOnce(); + }); + it('rolls back reconciliation epoch and journal when an AFTER UPDATE deletes its marker', async () => { + await fault('email_mutations', 'UPDATE', "IF NEW.state='reconciliation_required' THEN DELETE FROM email_mutations WHERE id=NEW.id; END IF;", 'AFTER'); + updateUser.mockRejectedValueOnce(new Error('timeout')); + await expect(mutate()).rejects.toMatchObject({ body: { reconciliation_required: true } }); + expect((await journal())[0].state).toBe('pending'); expect(await epoch()).toBe(0); expect(invalidate).not.toHaveBeenCalled(); + }); + it.each(['users','organization_memberships','person_relationships'])('detects suppressed %s local writes instead of reporting success', async table => { + await fault(table, 'UPDATE', 'RETURN NULL;'); + const before = await state(); const result = await mutate().catch(error => error); + expect(result).toHaveProperty('status'); expect(result.status).not.toBe(200); expect(await state()).toEqual(before); + expect(provider.email).toBe(oldEmail); + }); + it.each(['INSERT','DELETE'])('detects suppressed alias %s writes', async event => { + await fault('user_email_aliases', event, 'RETURN NULL;'); + const before = await state(); const result = await mutate().catch(error => error); + expect(result.status).not.toBe(200); expect(await state()).toEqual(before); expect(provider.email).toBe(oldEmail); + }); + it.each(['INSERT','UPDATE'])('cannot claim success or compensation after a suppressed epoch %s', async event => { + if (event === 'UPDATE') await pool.query('INSERT INTO authorization_epochs(workos_user_id,epoch) VALUES($1,7)', [userId]); + await fault('authorization_epochs', event, 'RETURN NULL;'); + const before = await state(); await expect(mutate()).rejects.toMatchObject({ body: { reconciliation_required: true } }); + expect(await state()).toEqual(before); expect(await epoch()).toBe(event === 'UPDATE' ? 7 : 0); expect(invalidate).not.toHaveBeenCalled(); + }); + it('detects an epoch trigger that returns a row without advancing its value', async () => { + await pool.query('INSERT INTO authorization_epochs(workos_user_id,epoch) VALUES($1,7)', [userId]); + await fault('authorization_epochs', 'UPDATE', 'NEW.epoch := OLD.epoch;'); + await expect(mutate()).rejects.toMatchObject({ body: { reconciliation_required: true } }); expect(await epoch()).toBe(7); + }); + it('compensates the provider when only the first epoch write is suppressed', async () => { + await pool.query('CREATE SEQUENCE test_email_mutation_fault_counter'); + await fault('authorization_epochs', 'INSERT OR UPDATE', "IF nextval('test_email_mutation_fault_counter')=1 THEN RETURN NULL; END IF;"); + const before = await state(); + await expect(mutate()).rejects.toMatchObject({ status: 503, body: { reconciliation_required: false } }); + expect(updateUser).toHaveBeenCalledTimes(2); expect(provider.email).toBe(oldEmail); expect(await state()).toEqual(before); + expect((await journal())[0]).toMatchObject({ state: 'compensated', epoch_after: '2' }); expect(await epoch()).toBe(2); + }); + it.each(['lost', 'rollback', 'unknown-marker'])('requires independent marker evidence after an intent COMMIT %s response', async mode => { + const verifier = vi.spyOn(databaseClient, 'getDedicatedClient'); + if (mode === 'unknown-marker') verifier.mockResolvedValueOnce({ + query: vi.fn().mockRejectedValue(new Error('marker unavailable')), end: vi.fn().mockResolvedValue(undefined), + } as any); + const connect = pool.connect.bind(pool); + vi.spyOn(pool, 'connect').mockImplementationOnce(async () => { + const client = await connect(); const query = client.query.bind(client); let injected = false; + vi.spyOn(client, 'query').mockImplementation(async (...args: any[]) => { + if (args[0] === 'COMMIT' && !injected) { + injected = true; + if (mode === 'rollback') return query('ROLLBACK'); + await (query as any)(...args); throw new Error('intent commit response lost'); + } + return (query as any)(...args); + }); + return client; + }); + if (mode === 'lost') { + await expect(mutate()).resolves.toMatchObject({ primary_email: newEmail }); + expect(updateUser).toHaveBeenCalledOnce(); + expect((await journal())[0].state).toBe('succeeded'); + } else { + await expect(mutate()).rejects.toMatchObject({ status: mode === 'unknown-marker' ? 409 : 503 }); + expect(getUser).not.toHaveBeenCalled(); expect(updateUser).not.toHaveBeenCalled(); + expect((await journal()).map(row => row.state)).toEqual(mode === 'unknown-marker' ? ['reconciliation_required'] : []); + } + expect(verifier).toHaveBeenCalled(); + }); + it.each(['lost', 'rollback'])('does not compensate when terminal COMMIT %s lacks a matching committed marker', async mode => { + const before = await state(); + const connect = pool.connect.bind(pool); + vi.spyOn(pool, 'connect').mockImplementationOnce(async () => { + const client = await connect(); const query = client.query.bind(client); let commits = 0; + vi.spyOn(client, 'query').mockImplementation(async (...args: any[]) => { + if (args[0] === 'COMMIT' && ++commits === 2) { + if (mode === 'rollback') return query('ROLLBACK'); + throw new Error('terminal commit outcome unknown'); + } + return (query as any)(...args); + }); + return client; + }); + await expect(mutate()).rejects.toMatchObject({ body: { reconciliation_required: true } }); + expect(await state()).toEqual(before); expect(provider.email).toBe(newEmail); expect(updateUser).toHaveBeenCalledOnce(); + expect((await journal())[0]).toMatchObject({ state: 'reconciliation_required', failure_code: 'local_commit_unknown' }); + }); + it.each([{ id: otherUserId }, { email: 'unexpected@test.example' }, { emailVerified: true }])('blocks an inconsistent provider baseline %j without writing to the provider', async difference => { + getUser.mockResolvedValueOnce({ ...provider, ...difference }); + const id = randomUUID(); await expect(mutate(id)).rejects.toMatchObject({ body: { reconciliation_required: true } }); + await expect(mutate(id)).rejects.toMatchObject({ body: { reconciliation_required: true } }); + expect(updateUser).not.toHaveBeenCalled(); expect(await epoch()).toBe(1); + }); + it('serializes actual concurrent requests while the provider mutation is pending', async () => { + let finish!: () => void; + const waiting = new Promise(resolve => { finish = resolve; }); + updateUser.mockImplementationOnce(async ({ email, emailVerified }) => { await waiting; provider = { id: userId, email, emailVerified }; return provider; }); + const first = mutate(); + try { + await vi.waitFor(() => expect(updateUser).toHaveBeenCalledOnce()); + await expect(mutate()).rejects.toMatchObject({ body: { error: 'credential_busy', retryable: true } }); + expect(await journal()).toHaveLength(1); + } finally { finish(); } + await first; expect(updateUser).toHaveBeenCalledOnce(); expect(await epoch()).toBe(1); + }); + it('uses a fresh committed marker to recover a lost COMMIT response without provider compensation', async () => { + const connect = pool.connect.bind(pool); + vi.spyOn(pool, 'connect').mockImplementationOnce(async () => { + const client = await connect(); const query = client.query.bind(client); let commits = 0; + vi.spyOn(client, 'query').mockImplementation(async (...args: any[]) => { const result = await (query as any)(...args); if (args[0] === 'COMMIT' && ++commits === 2) throw new Error('commit response lost'); return result; }); + return client; + }); + const id = randomUUID(); const result = await mutate(id); expect(result.primary_email).toBe(newEmail); + expect(await mutate(id)).toEqual(result); expect(updateUser).toHaveBeenCalledOnce(); expect(await epoch()).toBe(1); + }); + it('does not change a durable result when postcommit session eviction fails', async () => { + invalidate.mockImplementation(() => { throw new Error('secret local cache error'); }); + const id = randomUUID(); const first = await mutate(id); expect(await mutate(id)).toEqual(first); + expect(updateUser).toHaveBeenCalledOnce(); expect(await epoch()).toBe(1); + }); +}); diff --git a/server/tests/integration/email-writer-fence.test.ts b/server/tests/integration/email-writer-fence.test.ts new file mode 100644 index 00000000000..28be7ca522f --- /dev/null +++ b/server/tests/integration/email-writer-fence.test.ts @@ -0,0 +1,324 @@ +import { createHash, createHmac, randomUUID } from 'node:crypto'; +import express from 'express'; +import request from 'supertest'; +import type { Pool, PoolClient } from 'pg'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initializeDatabase, closeDatabase } from '../../src/db/client.js'; +import { runMigrations } from '../../src/db/migrate.js'; +import { getAuthorizationFingerprint } from '../../src/db/authorization-epoch-db.js'; + +vi.hoisted(() => { + vi.stubEnv('WORKOS_API_KEY', 'sk_test_writer_fence'); + vi.stubEnv('WORKOS_CLIENT_ID', 'client_writer_fence'); + vi.stubEnv('WORKOS_COOKIE_PASSWORD', 'writer-fence-cookie-password-at-least-32-characters'); + vi.stubEnv('WORKOS_WEBHOOK_SECRET', 'writer-fence-webhook-secret'); +}); +vi.mock('../../src/addie/error-notifier.js', () => ({ notifySystemError: vi.fn() })); +import { createWorkOSWebhooksRouter } from '../../src/routes/workos-webhooks.js'; + +const USER = 'user_email_writer_fence'; +const OTHER = 'user_email_writer_fence_other'; +const OLD = 'writer-fence-old@test.example'; +const NEW = 'writer-fence-new@test.example'; +const USER_IDS = [USER, OTHER]; + +// Uses actual PostgreSQL triggers and the signed HTTP webhook handler. No +// provider mutation service mock can hide a stale independent SQL writer. +describe('credential email writer fence and journal constraints', () => { + let pool: Pool; + const app = express(); + app.use('/api/webhooks', createWorkOSWebhooksRouter()); + + async function cleanup() { + await pool.query('DROP TRIGGER IF EXISTS test_email_writer_epoch ON authorization_epochs'); + await pool.query('DROP FUNCTION IF EXISTS test_email_writer_epoch()'); + await pool.query('DROP TRIGGER IF EXISTS zz_test_email_writer_journal ON email_mutations'); + await pool.query('DROP FUNCTION IF EXISTS test_email_writer_journal()'); + await pool.query('DELETE FROM email_mutations WHERE workos_user_id = ANY($1)', [USER_IDS]); + await pool.query('DELETE FROM person_relationships WHERE workos_user_id = ANY($1)', [USER_IDS]); + await pool.query('DELETE FROM organization_memberships WHERE workos_user_id = ANY($1)', [USER_IDS]); + await pool.query('DELETE FROM users WHERE workos_user_id = ANY($1)', [USER_IDS]); + } + + beforeAll(async () => { + pool = initializeDatabase({ connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@127.0.0.1:5432/adcp_member_fence_592' }); + await runMigrations(); + }, 60000); + beforeEach(async () => { + await cleanup(); + await pool.query( + `INSERT INTO users (workos_user_id, email, email_verified, first_name, last_name) + VALUES ($1, $3, FALSE, 'Alex', 'Reeves'), ($2, 'writer-fence-other@test.example', FALSE, 'Sam', 'Adeyemi')`, + [USER, OTHER, OLD], + ); + await pool.query('INSERT INTO user_email_aliases (workos_user_id, email) VALUES ($1, $2)', [USER, NEW]); + await pool.query( + `INSERT INTO email_link_tokens (token, primary_workos_user_id, target_email, expires_at) + VALUES ('writer_fence_token', $1, $2, NOW() + INTERVAL '1 day')`, [USER, NEW], + ); + await pool.query( + `INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role, workos_membership_id) + VALUES ($1, 'org_email_writer_fence', $2, 'owner', 'om_writer_fence')`, [USER, OLD], + ); + await pool.query('INSERT INTO person_relationships (workos_user_id, email) VALUES ($1, $2)', [USER, OLD]); + }); + afterEach(async () => { await cleanup(); }); + afterAll(async () => { await closeDatabase(); vi.unstubAllEnvs(); }); + + async function intent(overrides: Record = {}) { + const current = (await pool.query('SELECT email, email_verified, email_mutation_version FROM users WHERE workos_user_id = $1', [USER])).rows[0]; + const id = randomUUID(); + const row = { + id, workos_user_id: USER, actor_user_id: USER, + payload_hash: createHash('sha256').update(JSON.stringify([USER, NEW])).digest('hex'), + old_email: current.email, old_email_verified: current.email_verified, + new_email: NEW, expected_email_version: current.email_mutation_version, + state: 'pending', result_status: 409, + result_body: { operation_id: id, reconciliation_required: true }, + ...overrides, + }; + const fields = Object.keys(row); + const values = Object.values(row); + return (await pool.query( + `INSERT INTO email_mutations (${fields.join(', ')}) VALUES (${values.map((_, i) => `$${i + 1}`).join(', ')}) RETURNING *`, values, + )).rows[0]; + } + + async function snapshot() { + const results = await Promise.all([ + pool.query('SELECT email, email_verified, email_mutation_version FROM users WHERE workos_user_id = $1', [USER]), + pool.query('SELECT id, workos_user_id, workos_organization_id, workos_membership_id, email, role FROM organization_memberships WHERE workos_user_id = $1', [USER]), + pool.query('SELECT id, workos_user_id, email FROM person_relationships WHERE workos_user_id = $1', [USER]), + pool.query('SELECT id, workos_user_id, email, verified_at FROM user_email_aliases WHERE workos_user_id = $1', [USER]), + pool.query('SELECT id, primary_workos_user_id, target_email, status FROM email_link_tokens WHERE primary_workos_user_id = $1', [USER]), + ]); + return results.map((result) => result.rows); + } + + async function writeLocal(client: PoolClient, operation: any, state: 'succeeded' | 'compensated') { + await client.query("SELECT set_config('adcp.email_mutation_id', $1, true)", [operation.id]); + const email = state === 'succeeded' ? operation.new_email : operation.old_email; + const verified = state === 'succeeded' ? true : operation.old_email_verified; + const changed = await client.query( + 'UPDATE users SET email = $2, email_verified = $3 WHERE workos_user_id = $1 RETURNING email_mutation_version', + [USER, email, verified], + ); + expect(changed.rowCount).toBe(1); + await client.query('UPDATE organization_memberships SET email = $2 WHERE workos_user_id = $1', [USER, email]); + await client.query('UPDATE person_relationships SET email = $2 WHERE workos_user_id = $1', [USER, email]); + return changed.rows[0].email_mutation_version; + } + + async function terminal(operation: any, state: 'succeeded' | 'compensated') { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const version = await writeLocal(client, operation, state); + const epoch = await client.query( + `INSERT INTO authorization_epochs (workos_user_id, epoch) VALUES ($1, 1) + ON CONFLICT (workos_user_id) DO UPDATE SET epoch = authorization_epochs.epoch + 1 RETURNING epoch`, [USER], + ); + const body = state === 'succeeded' + ? { operation_id: operation.id, primary_email: operation.new_email } + : { operation_id: operation.id, error: 'Email change failed' }; + await client.query( + `UPDATE email_mutations SET state = $2, applied_email_version = $3, epoch_after = $4, + failure_code = $5, result_status = $6, result_body = $7 WHERE id = $1`, + [operation.id, state, version, epoch.rows[0].epoch, state === 'succeeded' ? null : 'local_write_failed', state === 'succeeded' ? 200 : 503, body], + ); + await client.query('COMMIT'); + } catch (error) { await client.query('ROLLBACK'); throw error; } + finally { client.release(); } + } + + it.each(['succeeded', 'compensated'] as const)('fences delayed original/compensation snapshots after %s, even with a newer provider timestamp', async (state) => { + const operation = await intent(); + await terminal(operation, state); + const before = await snapshot(); + const staleEmail = state === 'succeeded' ? OLD : NEW; + const staleVerified = state !== 'succeeded'; + await expect(pool.query( + `INSERT INTO users (workos_user_id, email, email_verified, workos_updated_at) + VALUES ($1, $2, $3, NOW() + INTERVAL '1 day') ON CONFLICT (workos_user_id) DO UPDATE + SET email = EXCLUDED.email, email_verified = EXCLUDED.email_verified, workos_updated_at = EXCLUDED.workos_updated_at`, + [USER, staleEmail, staleVerified], + )).rejects.toMatchObject({ code: '23514' }); + // WorkOS denormalizations run independently of its users upsert. + await pool.query('UPDATE organization_memberships SET email = $2 WHERE workos_user_id = $1', [USER, staleEmail]); + await pool.query('UPDATE person_relationships SET email = $2 WHERE workos_user_id = $1', [USER, staleEmail]); + expect(await snapshot()).toEqual(before); + expect(before[0][0].email_mutation_version).toBe('1'); + }); + + it('rejects an actual signed delayed user.updated webhook after compensation', async () => { + const operation = await intent(); + await terminal(operation, 'compensated'); + const before = await snapshot(); + const rawBody = JSON.stringify({ + id: 'event_writer_fence_delayed', event: 'user.updated', created_at: new Date().toISOString(), + data: { id: USER, email: NEW, email_verified: true, first_name: 'Alex', last_name: 'Reeves', + created_at: '2026-01-01T00:00:00Z', updated_at: '2099-01-01T00:00:00Z' }, + }); + const timestamp = Date.now().toString(); + const signature = createHmac('sha256', 'writer-fence-webhook-secret').update(`${timestamp}.${rawBody}`).digest('hex'); + await request(app).post('/api/webhooks/workos').set('Content-Type', 'application/json') + .set('WorkOS-Signature', `t=${timestamp}, v1=${signature}`).send(rawBody).expect(500); + expect(await snapshot()).toEqual(before); + }); + + it.each(['pending', 'reconciliation_required'] as const)('lets signed user.deleted revoke a %s credential through FK cascades while retaining its journal', async (state) => { + const operation = await intent(); + await pool.query('INSERT INTO authorization_epochs (workos_user_id, epoch) VALUES ($1, 3)', [USER]); + if (state === 'reconciliation_required') { + Object.assign(operation, (await pool.query( + `UPDATE email_mutations SET state = 'reconciliation_required', failure_code = 'provider_outcome_unknown', + epoch_after = 3 WHERE id = $1 RETURNING *`, [operation.id], + )).rows[0]); + } + const beforeFingerprint = await getAuthorizationFingerprint([USER]); + expect(beforeFingerprint).toBe(`${USER}:3`); + // Ordinary direct deletion is still refused while the parent exists. + await expect(pool.query('DELETE FROM user_email_aliases WHERE workos_user_id = $1', [USER])) + .rejects.toMatchObject({ code: '23514' }); + await expect(pool.query('DELETE FROM email_link_tokens WHERE primary_workos_user_id = $1', [USER])) + .rejects.toMatchObject({ code: '23514' }); + const rawBody = JSON.stringify({ + id: 'event_writer_fence_deleted', event: 'user.deleted', created_at: new Date().toISOString(), + data: { id: USER, email: OLD, email_verified: false, first_name: 'Alex', last_name: 'Reeves', + created_at: '2026-01-01T00:00:00Z', updated_at: new Date().toISOString() }, + }); + const timestamp = Date.now().toString(); + const signature = createHmac('sha256', 'writer-fence-webhook-secret').update(`${timestamp}.${rawBody}`).digest('hex'); + await request(app).post('/api/webhooks/workos').set('Content-Type', 'application/json') + .set('WorkOS-Signature', `t=${timestamp}, v1=${signature}`).send(rawBody).expect(200); + for (const table of ['users', 'identity_workos_users', 'authorization_epochs', 'organization_memberships', 'user_email_aliases']) { + expect((await pool.query(`SELECT 1 FROM ${table} WHERE workos_user_id = $1`, [USER])).rowCount).toBe(0); + } + expect((await pool.query('SELECT 1 FROM email_link_tokens WHERE primary_workos_user_id = $1', [USER])).rowCount).toBe(0); + expect(await getAuthorizationFingerprint([USER])).toBe(''); + expect(await getAuthorizationFingerprint([USER])).not.toBe(beforeFingerprint); + expect((await pool.query('SELECT * FROM email_mutations WHERE id = $1', [operation.id])).rows).toEqual([operation]); + }); + + it('preserves role revocations and profile updates while protecting pending or terminal email', async () => { + const operation = await intent(); + await pool.query("UPDATE organization_memberships SET role = 'member' WHERE workos_user_id = $1", [USER]); + await pool.query("UPDATE users SET first_name = 'Sam' WHERE workos_user_id = $1", [USER]); + await terminal(operation, 'succeeded'); + await pool.query("UPDATE organization_memberships SET email = $2, role = 'member' WHERE workos_user_id = $1", [USER, OLD]); + expect((await snapshot())[1][0]).toMatchObject({ email: NEW, role: 'member', workos_membership_id: 'om_writer_fence' }); + expect((await pool.query('SELECT first_name FROM users WHERE workos_user_id = $1', [USER])).rows[0].first_name).toBe('Sam'); + }); + + it('fails fast on a competing saga lock instead of deadlocking behind a row lock', async () => { + const client = await pool.connect(); + try { + await client.query('SELECT pg_advisory_lock(hashtextextended($1, 6827))', [USER]); + await expect(pool.query('UPDATE users SET email = $2 WHERE workos_user_id = $1', [USER, NEW])) + .rejects.toMatchObject({ code: '55P03' }); + await expect(pool.query('UPDATE person_relationships SET email = $2 WHERE workos_user_id = $1', [USER, NEW])) + .rejects.toMatchObject({ code: '55P03' }); + await expect(pool.query('UPDATE organization_memberships SET workos_user_id = $2 WHERE workos_user_id = $1', [USER, OTHER])) + .rejects.toMatchObject({ code: '55P03' }); + const result = await client.query('SELECT 1 FROM users WHERE workos_user_id = $1 FOR UPDATE', [USER]); + expect(result.rowCount).toBe(1); + } finally { + const unlock = await client.query('SELECT pg_advisory_unlock(hashtextextended($1, 6827)) AS unlocked', [USER]); + expect(unlock.rows[0].unlocked).toBe(true); + client.release(); + } + }); + + it('makes an ordinary external writer retain the same advisory lock until commit', async () => { + const writer = await pool.connect(); + const saga = await pool.connect(); + try { + await writer.query('BEGIN'); + await writer.query('UPDATE users SET email = $2 WHERE workos_user_id = $1', [USER, NEW]); + expect((await saga.query('SELECT pg_try_advisory_lock(hashtextextended($1, 6827)) AS locked', [USER])).rows[0].locked).toBe(false); + await writer.query('COMMIT'); + expect((await saga.query('SELECT pg_try_advisory_lock(hashtextextended($1, 6827)) AS locked', [USER])).rows[0].locked).toBe(true); + expect((await saga.query('SELECT pg_advisory_unlock(hashtextextended($1, 6827)) AS unlocked', [USER])).rows[0].unlocked).toBe(true); + expect((await snapshot())[0][0]).toMatchObject({ email: NEW, email_mutation_version: '1' }); + expect((await pool.query('SELECT epoch FROM authorization_epochs WHERE workos_user_id = $1', [USER])).rows[0].epoch).toBe('1'); + } finally { await writer.query('ROLLBACK'); writer.release(); saga.release(); } + }); + + it.each(['skip', 'stale'] as const)('rolls back an ordinary email writer when an epoch trigger returns %s evidence', async (mode) => { + await pool.query(`CREATE FUNCTION test_email_writer_epoch() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN ${mode === 'skip' ? 'RETURN NULL;' : 'NEW.epoch := 0; RETURN NEW;'} END; $$`); + await pool.query('CREATE TRIGGER test_email_writer_epoch BEFORE INSERT OR UPDATE ON authorization_epochs FOR EACH ROW EXECUTE FUNCTION test_email_writer_epoch()'); + const before = await snapshot(); + await expect(pool.query('UPDATE users SET email = $2 WHERE workos_user_id = $1', [USER, NEW])).rejects.toMatchObject({ code: '23514' }); + expect(await snapshot()).toEqual(before); + }); + + it('blocks pending alias/token deletion and reassignment from either credential direction', async () => { + await pool.query('INSERT INTO user_email_aliases (workos_user_id, email) VALUES ($1, $2)', [OTHER, 'writer-fence-other-alias@test.example']); + await intent(); + const before = await snapshot(); + for (const owner of [USER, OTHER]) { + await expect(pool.query('UPDATE user_email_aliases SET workos_user_id = $2 WHERE workos_user_id = $1', [owner, owner === USER ? OTHER : USER])) + .rejects.toMatchObject({ code: '23514' }); + } + await expect(pool.query('DELETE FROM user_email_aliases WHERE workos_user_id = $1', [USER])).rejects.toMatchObject({ code: '23514' }); + await expect(pool.query('DELETE FROM email_link_tokens WHERE primary_workos_user_id = $1', [USER])).rejects.toMatchObject({ code: '23514' }); + expect(await snapshot()).toEqual(before); + }); + + it('rejects boolean or wrong-operation GUC bypasses and unauthorized target pairs', async () => { + const operation = await intent(); + const client = await pool.connect(); + try { + for (const operationId of ['true', randomUUID(), operation.id]) { + await client.query('BEGIN'); + await client.query("SELECT set_config('adcp.email_mutation_id', $1, true)", [operationId]); + await expect(client.query('UPDATE users SET email = $2, email_verified = TRUE WHERE workos_user_id = $1', [USER, 'unapproved@test.example'])) + .rejects.toMatchObject({ code: '23514' }); + await client.query('ROLLBACK'); + } + } finally { client.release(); } + }); + + it('requires terminal journal evidence even if local user and denormalization writes succeeded', async () => { + const operation = await intent(); + const before = await snapshot(); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await writeLocal(client, operation, 'succeeded'); + await expect(client.query('COMMIT')).rejects.toMatchObject({ code: '23514' }); + } finally { await client.query('ROLLBACK'); client.release(); } + expect(await snapshot()).toEqual(before); + }); + + it('rejects a trigger that invents terminal journal evidence without a local mutation', async () => { + await pool.query(`CREATE FUNCTION test_email_writer_journal() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN + NEW.state := 'succeeded'; NEW.applied_email_version := NEW.expected_email_version + 1; + NEW.epoch_after := 1; NEW.result_status := 200; + NEW.result_body := jsonb_build_object('operation_id', NEW.id::text, 'primary_email', NEW.new_email); + RETURN NEW; END; $$`); + await pool.query('CREATE TRIGGER zz_test_email_writer_journal BEFORE INSERT ON email_mutations FOR EACH ROW EXECUTE FUNCTION test_email_writer_journal()'); + await expect(intent()).rejects.toMatchObject({ code: '23514' }); + expect((await pool.query('SELECT id FROM email_mutations WHERE workos_user_id = $1', [USER])).rows).toEqual([]); + }); + + it.each([ + { payload_hash: 'not-a-hash' }, { reconciliation_attempts: {} }, + { failure_code: 'unexpected' }, { result_status: 200 }, + { result_body: { reconciliation_required: true } }, + { result_body: [] }, { expected_email_version: -1 }, + ])('rejects malformed durable intent %j', async (badFields) => { + await expect(intent(badFields)).rejects.toMatchObject({ code: '23514' }); + }); + + it('keeps caller operation IDs unique and terminal replay evidence immutable', async () => { + const operation = await intent(); + await expect(intent({ id: operation.id, result_body: operation.result_body })).rejects.toMatchObject({ code: '23505' }); + await terminal(operation, 'succeeded'); + await expect(pool.query("UPDATE email_mutations SET result_body = result_body || '{\"primary_email\":\"other@test.example\"}'::jsonb WHERE id = $1", [operation.id])) + .rejects.toMatchObject({ code: '23514' }); + expect((await pool.query('SELECT payload_hash, result_body FROM email_mutations WHERE id = $1', [operation.id])).rows[0]) + .toMatchObject({ payload_hash: operation.payload_hash, result_body: { operation_id: operation.id, primary_email: NEW } }); + }); +}); diff --git a/server/tests/integration/user-authorization-snapshot.test.ts b/server/tests/integration/user-authorization-snapshot.test.ts new file mode 100644 index 00000000000..11886bab5ba --- /dev/null +++ b/server/tests/integration/user-authorization-snapshot.test.ts @@ -0,0 +1,507 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Pool, PoolClient } from 'pg'; +import { closeDatabase, initializeDatabase } from '../../src/db/client.js'; +import * as database from '../../src/db/client.js'; +import { runMigrations } from '../../src/db/migrate.js'; +import { + AuthorizationSnapshotUnavailableError, + loadAuthorizationSnapshot, + sameAuthorizationIdentity, + sameAuthorizationSnapshot, + type AuthorizationSnapshot, +} from '../../src/db/user-authorization-snapshot-db.js'; +import { bumpAuthorizationEpochs } from '../../src/db/authorization-epoch-db.js'; + +const PERSONAL_ID = 'user_authorization_snapshot_personal'; +const CORPORATE_ID = 'user_authorization_snapshot_corporate'; +const PERSONAL_ORG = 'org_authorization_snapshot_personal'; +const CORPORATE_ORG = 'org_authorization_snapshot_corporate'; +const USER_IDS = [PERSONAL_ID, CORPORATE_ID]; +const ORG_IDS = [PERSONAL_ORG, CORPORATE_ORG]; +const NEW_EMAIL = 'sam.updated@example.test'; +interface EmailIntent { + id: string; + workos_user_id: string; + old_email: string; + old_email_verified: boolean; + new_email: string; +} + +describe('primary database authorization snapshots', () => { + let pool: Pool; + let fixtureIdentityIds: string[] = []; + + beforeAll(async () => { + pool = initializeDatabase({ + connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test', + }); + await runMigrations(); + }, 60000); + + async function cleanup() { + await pool.query('DELETE FROM email_mutations WHERE workos_user_id = ANY($1)', [USER_IDS]); + await pool.query('DELETE FROM organization_memberships WHERE workos_organization_id = ANY($1)', [ORG_IDS]); + await pool.query('DELETE FROM organizations WHERE workos_organization_id = ANY($1)', [ORG_IDS]); + const identities = await pool.query<{ identity_id: string }>( + 'SELECT identity_id FROM identity_workos_users WHERE workos_user_id = ANY($1)', [USER_IDS], + ); + await pool.query('DELETE FROM users WHERE workos_user_id = ANY($1)', [USER_IDS]); + await pool.query('DELETE FROM identities WHERE id = ANY($1)', [ + [...fixtureIdentityIds, ...identities.rows.map(row => row.identity_id)], + ]); + fixtureIdentityIds = []; + } + + beforeEach(async () => { + vi.restoreAllMocks(); + await cleanup(); + await pool.query( + `INSERT INTO users (workos_user_id, email, first_name, last_name, email_verified, + workos_created_at, workos_updated_at) + VALUES ($1, 'sam.personal@example.test', 'Sam', 'Adeyemi', true, NOW(), NOW()), + ($2, 'sam@pinnacle.example', 'Sam', 'Adeyemi', true, NOW(), NOW())`, + USER_IDS, + ); + await pool.query( + `INSERT INTO organizations (workos_organization_id, name) + VALUES ($1, 'Sam personal organization'), ($2, 'Pinnacle Agency')`, + ORG_IDS, + ); + const identities = await pool.query<{ identity_id: string }>( + 'SELECT identity_id FROM identity_workos_users WHERE workos_user_id = ANY($1)', [USER_IDS], + ); + fixtureIdentityIds = identities.rows.map(row => row.identity_id); + }); + + afterAll(async () => { + vi.restoreAllMocks(); + if (pool) await cleanup(); + await closeDatabase(); + }); + + async function snapshot(userId = PERSONAL_ID, organizationId: string | null = null) { + const result = await loadAuthorizationSnapshot(userId, organizationId); + expect(result).not.toBeNull(); + return result!; + } + + async function link(primaryId: string, secondaryId: string, db: Pool | PoolClient = pool) { + await db.query( + `UPDATE identity_workos_users + SET identity_id = (SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1), + is_primary = FALSE + WHERE workos_user_id = $2`, + [primaryId, secondaryId], + ); + } + + async function emailIntent(userId = PERSONAL_ID, db: Pool | PoolClient = pool): Promise { + const id = randomUUID(); + const result = await db.query( + `INSERT INTO email_mutations + (id, workos_user_id, actor_user_id, payload_hash, old_email, old_email_verified, + new_email, expected_email_version, state, result_status, result_body) + SELECT $1, workos_user_id, workos_user_id, $3, email, email_verified, + $4, email_mutation_version, 'pending', 409, $5 + FROM users WHERE workos_user_id = $2 RETURNING *`, + [id, userId, createHash('sha256').update(JSON.stringify([userId, NEW_EMAIL])).digest('hex'), + NEW_EMAIL, { operation_id: id, reconciliation_required: true }], + ); + expect(result.rowCount).toBe(1); + return result.rows[0]; + } + + async function transitionEmailIntent( + db: PoolClient, operation: EmailIntent, state: 'reconciliation_required' | 'succeeded' | 'compensated', + ) { + let version: string | null = null; + if (state !== 'reconciliation_required') { + await db.query("SELECT set_config('adcp.email_mutation_id', $1, true)", [operation.id]); + const changed = await db.query<{ email_mutation_version: string }>( + 'UPDATE users SET email = $2, email_verified = $3 WHERE workos_user_id = $1 RETURNING email_mutation_version', + [operation.workos_user_id, state === 'succeeded' ? operation.new_email : operation.old_email, + state === 'succeeded' ? true : operation.old_email_verified], + ); + expect(changed.rowCount).toBe(1); + version = changed.rows[0].email_mutation_version; + } + await bumpAuthorizationEpochs(db, [operation.workos_user_id]); + const body = state === 'succeeded' + ? { operation_id: operation.id, primary_email: operation.new_email } + : state === 'compensated' + ? { operation_id: operation.id, error: 'Email change failed' } + : { operation_id: operation.id, reconciliation_required: true }; + const changed = await db.query( + `UPDATE email_mutations SET state = $2, applied_email_version = $3, + epoch_after = (SELECT epoch FROM authorization_epochs WHERE workos_user_id = $4), + failure_code = $5, result_status = $6, result_body = $7 WHERE id = $1`, + [operation.id, state, version, operation.workos_user_id, state === 'succeeded' ? null : 'provider_failed', + state === 'succeeded' ? 200 : state === 'compensated' ? 503 : 409, body], + ); + expect(changed.rowCount).toBe(1); + } + + async function grant( + userId: string, + organizationId: string, + options: { from?: string; until?: string | null; revoked?: boolean; role?: 'member' | 'admin' | 'owner' } = {}, + ) { + const result = await pool.query<{ id: string }>( + `INSERT INTO organization_credential_grants + (workos_user_id, workos_organization_id, role, granted_by_workos_user_id, + effective_from, effective_until, revoked_at, revoked_by_workos_user_id) + VALUES ($1, $2, $3, $1, $4, $5, + CASE WHEN $6 THEN NOW() ELSE NULL END, + CASE WHEN $6 THEN $1::varchar ELSE NULL END) + RETURNING id`, + [userId, organizationId, options.role ?? 'member', options.from ?? '2000-01-01T00:00:00.123456Z', + options.until ?? null, options.revoked ?? false], + ); + return result.rows[0].id; + } + + it('hydrates a cold exact credential with epoch zero and no implicit organization', async () => { + await grant(PERSONAL_ID, PERSONAL_ORG); + await pool.query( + `INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role) + VALUES ($1, $2, 'sam.personal@example.test', 'owner')`, + [PERSONAL_ID, CORPORATE_ORG], + ); + + const cold = await snapshot(); + expect(cold).toMatchObject({ + authenticatedUserId: PERSONAL_ID, + canonicalUserId: PERSONAL_ID, + authorizationEpoch: '0', + selectedOrganizationId: null, + credentialGrant: null, + credential: { email: 'sam.personal@example.test', firstName: 'Sam', lastName: 'Adeyemi', emailMutationPending: false }, + }); + expect(cold.identityId).toBeTruthy(); + expect(Object.isFrozen(cold)).toBe(true); + expect(Object.isFrozen(cold.credential)).toBe(true); + expect(await snapshot(PERSONAL_ID, '')).toEqual(cold); + expect((await snapshot(PERSONAL_ID, CORPORATE_ORG)).credentialGrant).toBeNull(); + }); + + it.each([ + { direction: 'personal primary', primaryId: PERSONAL_ID, secondaryId: CORPORATE_ID }, + { direction: 'corporate primary', primaryId: CORPORATE_ID, secondaryId: PERSONAL_ID }, + ])('reads pending intent only for the exact credential with $direction', async ({ primaryId, secondaryId }) => { + await link(primaryId, secondaryId); + const before = await snapshot(secondaryId); + await emailIntent(secondaryId); + const after = await snapshot(secondaryId); + expect(after.authorizationEpoch).toBe(before.authorizationEpoch); + expect(after.credential.emailMutationPending).toBe(true); + expect((await snapshot(primaryId)).credential.emailMutationPending).toBe(false); + expect(sameAuthorizationIdentity(before, after)).toBe(false); + expect(sameAuthorizationIdentity(after, before)).toBe(false); + }); + + it.each(['pending', 'reconciliation_required', 'succeeded', 'compensated'] as const)( + 'observes the %s transition atomically with credential fields and epoch', async state => { + const operation = state === 'pending' ? null : await emailIntent(); + const before = await snapshot(); + const writer = await pool.connect(); + let releaseRead!: () => void; + let readCompleted!: () => void; + const readBarrier = new Promise(resolve => { readCompleted = resolve; }); + const deliveryBarrier = new Promise(resolve => { releaseRead = resolve; }); + let pendingRead: Promise | undefined; + const actualQuery = database.queryWithTimeout; + try { + await writer.query('BEGIN'); + if (state === 'pending') await emailIntent(PERSONAL_ID, writer); + else await transitionEmailIntent(writer, operation!, state); + expect(await snapshot()).toEqual(before); + + // Hold the real SELECT result across the commit. A separate pending + // read would combine old credential/epoch with new journal state. + const readSpy = vi.spyOn(database, 'queryWithTimeout').mockImplementationOnce(async (...args) => { + const result = await actualQuery(...args); + readCompleted(); + await deliveryBarrier; + return result; + }); + pendingRead = loadAuthorizationSnapshot(PERSONAL_ID, null); + await Promise.race([readBarrier, pendingRead.then(() => { + throw new Error('Snapshot returned without waiting for the database read'); + })]); + await writer.query('COMMIT'); + releaseRead(); + expect(await pendingRead).toEqual(before); + expect(readSpy).toHaveBeenCalledTimes(1); + readSpy.mockRestore(); + + const after = await snapshot(); + expect(after.credential.emailMutationPending).toBe(state === 'pending' || state === 'reconciliation_required'); + expect(after.credential.email).toBe(state === 'succeeded' ? NEW_EMAIL : before.credential.email); + expect(after.credential.emailVerified).toBe(true); + expect(after.authorizationEpoch).toBe(state === 'pending' ? before.authorizationEpoch : '1'); + expect(sameAuthorizationIdentity(before, after)).toBe(false); + expect(sameAuthorizationSnapshot(before, after)).toBe(false); + expect(sameAuthorizationSnapshot(after, before)).toBe(false); + } finally { + releaseRead(); + await writer.query('ROLLBACK'); + await pendingRead?.catch(() => undefined); + vi.restoreAllMocks(); + writer.release(); + } + }, + ); + + it.each([ + { direction: 'personal primary', primaryId: PERSONAL_ID, secondaryId: CORPORATE_ID }, + { direction: 'corporate primary', primaryId: CORPORATE_ID, secondaryId: PERSONAL_ID }, + ])('keeps exact credential grants isolated with $direction', async ({ primaryId, secondaryId }) => { + await link(primaryId, secondaryId); + const personalGrantId = await grant(PERSONAL_ID, PERSONAL_ORG); + const corporateGrantId = await grant(CORPORATE_ID, CORPORATE_ORG, { role: 'admin' }); + // Stale consolidated membership state is deliberately stronger than the + // exact grants. Neither identity direction may inherit it as authority. + await pool.query( + `INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role) + VALUES ($1, $2, 'sam@pinnacle.example', 'owner'), + ($1, $3, 'sam@pinnacle.example', 'owner')`, + [primaryId, PERSONAL_ORG, CORPORATE_ORG], + ); + + const personal = await snapshot(PERSONAL_ID, PERSONAL_ORG); + const corporate = await snapshot(CORPORATE_ID, CORPORATE_ORG); + expect(personal.canonicalUserId).toBe(primaryId); + expect(corporate.canonicalUserId).toBe(primaryId); + expect(personal.identityId).toBe(corporate.identityId); + expect(personal.credential.email).toBe('sam.personal@example.test'); + expect(corporate.credential.email).toBe('sam@pinnacle.example'); + expect(personal.credentialGrant).toEqual({ + id: personalGrantId, organizationId: PERSONAL_ORG, role: 'member', + effectiveFrom: '2000-01-01T00:00:00.123456Z', effectiveUntil: null, + }); + expect(corporate.credentialGrant?.id).toBe(corporateGrantId); + expect(corporate.credentialGrant?.role).toBe('admin'); + expect(Object.isFrozen(personal.credentialGrant)).toBe(true); + expect((await snapshot(PERSONAL_ID, CORPORATE_ORG)).credentialGrant).toBeNull(); + expect((await snapshot(CORPORATE_ID, PERSONAL_ORG)).credentialGrant).toBeNull(); + expect((await snapshot(secondaryId, null)).credentialGrant).toBeNull(); + expect(sameAuthorizationIdentity(personal, corporate)).toBe(false); + }); + + it.each([ + { state: 'expired', from: '2000-01-01T00:00:00Z', until: '2001-01-01T00:00:00Z' }, + { state: 'future', from: '2999-01-01T00:00:00Z', until: null }, + { state: 'revoked', revoked: true }, + ])('excludes $state grants on a cold read', async options => { + await grant(PERSONAL_ID, PERSONAL_ORG, options); + const cold = await snapshot(PERSONAL_ID, PERSONAL_ORG); + expect(cold.selectedOrganizationId).toBe(PERSONAL_ORG); + expect(cold.credentialGrant).toBeNull(); + }); + + it('does not reuse a previously loaded grant after revocation', async () => { + const grantId = await grant(PERSONAL_ID, PERSONAL_ORG); + const cached = await snapshot(PERSONAL_ID, PERSONAL_ORG); + await pool.query( + `UPDATE organization_credential_grants + SET revoked_at = NOW(), revoked_by_workos_user_id = $2 WHERE id = $1`, + [grantId, PERSONAL_ID], + ); + const fresh = await snapshot(PERSONAL_ID, PERSONAL_ORG); + expect(fresh.credentialGrant).toBeNull(); + expect(sameAuthorizationIdentity(cached, fresh)).toBe(true); + expect(sameAuthorizationSnapshot(cached, fresh)).toBe(false); + }); + + it('preserves bigint epochs and rejects forwards and backwards replay', async () => { + await pool.query( + 'INSERT INTO authorization_epochs (workos_user_id, epoch) VALUES ($1, $2)', + [PERSONAL_ID, '9007199254740992'], + ); + const old = await snapshot(); + await bumpAuthorizationEpochs(pool, [PERSONAL_ID]); + const bumped = await snapshot(); + expect(old.authorizationEpoch).toBe('9007199254740992'); + expect(bumped.authorizationEpoch).toBe('9007199254740993'); + expect(sameAuthorizationSnapshot(old, bumped)).toBe(false); + expect(sameAuthorizationSnapshot(bumped, old)).toBe(false); + await pool.query('DELETE FROM authorization_epochs WHERE workos_user_id = $1', [PERSONAL_ID]); + const absent = await snapshot(); + expect(absent.authorizationEpoch).toBe('0'); + expect(sameAuthorizationSnapshot(bumped, absent)).toBe(false); + }); + + it('rejects replays with altered credential, identity, selection, or grant details', async () => { + await grant(PERSONAL_ID, PERSONAL_ORG, { until: '2999-01-01T00:00:00.654321Z' }); + const current = await snapshot(PERSONAL_ID, PERSONAL_ORG); + expect(current.credentialGrant?.effectiveUntil).toBe('2999-01-01T00:00:00.654321Z'); + expect(sameAuthorizationSnapshot(current, structuredClone(current))).toBe(true); + const identityChanges: Partial[] = [ + { authenticatedUserId: CORPORATE_ID }, + { canonicalUserId: CORPORATE_ID }, + { identityId: null }, + { authorizationEpoch: '1' }, + { credential: { ...current.credential, email: 'changed@example.test' } }, + { credential: { ...current.credential, emailMutationPending: true } }, + ]; + for (const change of identityChanges) { + expect(sameAuthorizationIdentity(current, { ...current, ...change })).toBe(false); + expect(sameAuthorizationSnapshot(current, { ...current, ...change })).toBe(false); + } + expect(sameAuthorizationSnapshot(current, { ...current, selectedOrganizationId: CORPORATE_ORG })).toBe(false); + expect(sameAuthorizationSnapshot(current, { ...current, credentialGrant: null })).toBe(false); + const grantChanges: Partial>[] = [ + { id: 'replacement-grant' }, + { organizationId: CORPORATE_ORG }, + { role: 'owner' }, + { effectiveFrom: '2000-01-01T00:00:00.123457Z' }, + { effectiveUntil: '2999-01-01T00:00:00.654322Z' }, + ]; + for (const change of grantChanges) { + expect(sameAuthorizationSnapshot(current, { + ...current, credentialGrant: { ...current.credentialGrant!, ...change }, + })).toBe(false); + } + }); + + it.each([ + { direction: 'personal into corporate', primaryId: CORPORATE_ID, secondaryId: PERSONAL_ID }, + { direction: 'corporate into personal', primaryId: PERSONAL_ID, secondaryId: CORPORATE_ID }, + ])('cannot stamp old identity with a newly committed epoch: $direction', async ({ primaryId, secondaryId }) => { + const before = await snapshot(secondaryId); + const writer = await pool.connect(); + let releaseRead!: () => void; + let readCompleted!: () => void; + const readBarrier = new Promise(resolve => { readCompleted = resolve; }); + const deliveryBarrier = new Promise(resolve => { releaseRead = resolve; }); + let pendingRead: Promise | undefined; + const actualQuery = database.queryWithTimeout; + try { + await writer.query('BEGIN'); + await link(primaryId, secondaryId, writer); + // An observer while identity changed but before the transactional epoch + // write still sees the complete old state through PostgreSQL MVCC. + expect(await snapshot(secondaryId)).toEqual(before); + await bumpAuthorizationEpochs(writer, [primaryId, secondaryId]); + + // Hold delivery of the first real SQL result while the writer commits. + // A split identity/epoch loader would then issue its epoch query against + // the new commit and return old identity + new epoch. No rows are mocked. + const readSpy = vi.spyOn(database, 'queryWithTimeout').mockImplementationOnce(async (...args) => { + const result = await actualQuery(...args); + readCompleted(); + await deliveryBarrier; + return result; + }); + pendingRead = loadAuthorizationSnapshot(secondaryId, null); + await Promise.race([readBarrier, pendingRead.then(() => { + throw new Error('Snapshot returned without waiting for the database read'); + })]); + await writer.query('COMMIT'); + releaseRead(); + const overlapping = await pendingRead; + expect(overlapping).toEqual(before); + expect(readSpy).toHaveBeenCalledTimes(1); + readSpy.mockRestore(); + + const after = await snapshot(secondaryId); + expect(after.canonicalUserId).toBe(primaryId); + expect(after.identityId).not.toBe(before.identityId); + expect(after.authorizationEpoch).toBe('1'); + expect(sameAuthorizationSnapshot(before, after)).toBe(false); + expect(sameAuthorizationSnapshot(after, before)).toBe(false); + } finally { + releaseRead(); + await writer.query('ROLLBACK'); + await pendingRead?.catch(() => undefined); + vi.restoreAllMocks(); + writer.release(); + } + }); + + it('returns null for a missing exact credential, without using its former canonical person', async () => { + await link(CORPORATE_ID, PERSONAL_ID); + const cached = await snapshot(PERSONAL_ID); + await pool.query('DELETE FROM users WHERE workos_user_id = $1', [PERSONAL_ID]); + expect(await loadAuthorizationSnapshot(PERSONAL_ID, CORPORATE_ORG)).toBeNull(); + expect((await snapshot(CORPORATE_ID)).canonicalUserId).toBe(cached.canonicalUserId); + }); + + it.each(['binding', 'primary'])('fails unavailable for a missing identity %s', async missing => { + if (missing === 'binding') { + await pool.query('DELETE FROM identity_workos_users WHERE workos_user_id = $1', [PERSONAL_ID]); + } else { + await pool.query('UPDATE identity_workos_users SET is_primary = FALSE WHERE workos_user_id = $1', [PERSONAL_ID]); + } + await expect(loadAuthorizationSnapshot(PERSONAL_ID, null)) + .rejects.toBeInstanceOf(AuthorizationSnapshotUnavailableError); + }); + + it('fails unavailable on database errors instead of replaying a warm snapshot', async () => { + await snapshot(PERSONAL_ID, PERSONAL_ORG); + vi.spyOn(database, 'queryWithTimeout').mockRejectedValueOnce(new Error('private database connection details')); + await expect(loadAuthorizationSnapshot(PERSONAL_ID, PERSONAL_ORG)) + .rejects.toThrow(new AuthorizationSnapshotUnavailableError()); + }); + + it.each([ + { state: 'valid credential', hasUser: true }, + { state: 'absent credential', hasUser: false }, + ])('rejects a recovering replica with $state', async ({ hasUser }) => { + const primary = await snapshot(PERSONAL_ID, PERSONAL_ORG); + // The integration database is a primary. Simulate only the single query + // result from a recovering replica, including its no-user anchor row. + const readSpy = vi.spyOn(database, 'queryWithTimeout').mockResolvedValueOnce({ + rows: [{ + in_recovery: true, + authenticated_user_id: hasUser ? primary.authenticatedUserId : null, + canonical_user_id: hasUser ? primary.canonicalUserId : null, + identity_id: hasUser ? primary.identityId : null, + authorization_epoch: '0', + email: hasUser ? primary.credential.email : null, + email_verified: hasUser ? primary.credential.emailVerified : null, + email_mutation_pending: false, + first_name: hasUser ? primary.credential.firstName : null, + last_name: hasUser ? primary.credential.lastName : null, + grant_id: null, + grant_organization_id: null, + grant_role: null, + grant_effective_from: null, + grant_effective_until: null, + }], + rowCount: 1, + command: 'SELECT', + oid: 0, + fields: [], + }); + await expect(loadAuthorizationSnapshot(PERSONAL_ID, PERSONAL_ORG)) + .rejects.toBeInstanceOf(AuthorizationSnapshotUnavailableError); + expect(readSpy).toHaveBeenCalledTimes(1); + }); + + it('expires a blocked statement, releases its transaction, and loads fresh authority after recovery', async () => { + const before = await snapshot(PERSONAL_ID, PERSONAL_ORG); + const writer = await pool.connect(); + const boundedQuery = vi.spyOn(database, 'queryWithTimeout'); + try { + await writer.query('BEGIN'); + await writer.query('LOCK TABLE identity_workos_users IN ACCESS EXCLUSIVE MODE'); + const startedAt = Date.now(); + await expect(loadAuthorizationSnapshot(PERSONAL_ID, PERSONAL_ORG)) + .rejects.toBeInstanceOf(AuthorizationSnapshotUnavailableError); + expect(Date.now() - startedAt).toBeLessThan(5_000); + expect(boundedQuery).toHaveBeenCalledTimes(1); + + await bumpAuthorizationEpochs(writer, [PERSONAL_ID]); + await writer.query('COMMIT'); + const after = await snapshot(PERSONAL_ID, PERSONAL_ORG); + expect(after.authorizationEpoch).toBe('1'); + expect(sameAuthorizationSnapshot(before, after)).toBe(false); + expect(boundedQuery).toHaveBeenCalledTimes(2); + } finally { + await writer.query('ROLLBACK'); + writer.release(); + } + }); +}); diff --git a/server/tests/unit/aao-admin-status.test.ts b/server/tests/unit/aao-admin-status.test.ts index 5a10031722d..5b6200b581c 100644 --- a/server/tests/unit/aao-admin-status.test.ts +++ b/server/tests/unit/aao-admin-status.test.ts @@ -17,7 +17,17 @@ import { isWebUserAAOAdmin, resolveWebUserAAOAdminAccess, } from '../../src/addie/admin-status-lookup.js'; -import { isBreakGlassAdminEmail } from '../../src/auth/admin-access.js'; +import { decideAAOAdminAccess, isBreakGlassAdmin } from '../../src/auth/admin-access.js'; +import type { AuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; + +function snapshot(overrides: Partial = {}): AuthorizationSnapshot { + return { + authenticatedUserId: 'user_no_membership', canonicalUserId: 'user_no_membership', + identityId: 'identity_admin_test', selectedOrganizationId: null, authorizationEpoch: '1', + credentialGrant: null, + credential: { email: 'BREAK-GLASS@example.test', emailVerified: true, emailMutationPending: false, firstName: null, lastName: null, ...overrides }, + }; +} import { getSlackAdminStatusCache, getWebAdminStatusCache, @@ -76,8 +86,40 @@ describe('site-admin access decisions', () => { it('identifies the environment-only break-glass mechanism separately', async () => { mocks.isMember.mockResolvedValue(false); - expect(isBreakGlassAdminEmail('BREAK-GLASS@example.test')).toBe(true); - await expect(resolveWebUserAAOAdminAccess('user_no_membership', 'break-glass@example.test')) + expect(isBreakGlassAdmin(snapshot())).toBe(true); + await expect(resolveWebUserAAOAdminAccess('user_no_membership', snapshot())) .resolves.toEqual({ isAdmin: true, mechanism: 'break_glass_admin_email' }); }); + + it.each([true, undefined, null, 'false'])('denies unresolved or malformed mutation state %s', (emailMutationPending) => { + const state = snapshot({ emailMutationPending: emailMutationPending as boolean }); + expect(isBreakGlassAdmin(state)).toBe(false); + expect(decideAAOAdminAccess(false, state)).toEqual({ isAdmin: false, mechanism: null }); + }); + + it.each([undefined, null])('denies missing authoritative snapshot %s', (state) => { + expect(isBreakGlassAdmin(state)).toBe(false); + }); + + it('requires verified local email and never a provider email fallback', () => { + expect(isBreakGlassAdmin(snapshot({ emailVerified: false }))).toBe(false); + expect(isBreakGlassAdmin(snapshot({ email: 'ordinary@example.test' }))).toBe(false); + expect(isBreakGlassAdmin(snapshot({ email: null }))).toBe(false); + }); + + it('rechecks mutation state instead of caching an email-based admin grant', async () => { + mocks.isMember.mockResolvedValue(false); + await expect(resolveWebUserAAOAdminAccess('user_no_membership', snapshot())) + .resolves.toEqual({ isAdmin: true, mechanism: 'break_glass_admin_email' }); + await expect(resolveWebUserAAOAdminAccess('user_no_membership', snapshot({ emailMutationPending: true }))) + .resolves.toEqual({ isAdmin: false, mechanism: null }); + await expect(resolveWebUserAAOAdminAccess('user_no_membership', snapshot())) + .resolves.toEqual({ isAdmin: true, mechanism: 'break_glass_admin_email' }); + }); + + it('keeps independently granted working-group authority distinct from break glass', () => { + expect(decideAAOAdminAccess(true, snapshot({ emailMutationPending: true }))) + .toEqual({ isAdmin: true, mechanism: 'aao_admin_working_group' }); + }); + }); diff --git a/server/tests/unit/addie-admin-global-auth.test.ts b/server/tests/unit/addie-admin-global-auth.test.ts index 38fa4c89bee..2f6a9778f86 100644 --- a/server/tests/unit/addie-admin-global-auth.test.ts +++ b/server/tests/unit/addie-admin-global-auth.test.ts @@ -51,6 +51,7 @@ vi.mock('../../src/db/org-filters.js', () => ({ vi.mock('../../src/db/client.js', async (importOriginal) => ({ ...(await importOriginal()), getPool: () => ({ query: mocks.poolQuery }), + queryWithTimeout: mocks.poolQuery, })); vi.mock('../../src/db/working-group-db.js', () => ({ @@ -130,9 +131,17 @@ describe('Addie real global-admin boundary', () => { }); mocks.isAdminGroupMember.mockResolvedValue(true); mocks.poolQuery.mockImplementation((sql: string) => { - if (sql.includes('FROM users')) { + if (sql.includes('pg_catalog.pg_is_in_recovery()')) { return Promise.resolve({ - rows: [{ first_name: 'SSO', last_name: 'Admin' }], + rows: [{ + in_recovery: false, + authenticated_user_id: 'user_sso_admin', + canonical_user_id: 'user_sso_admin', + identity_id: 'identity_sso_admin', + authorization_epoch: '0', + email: 'sso-admin@example.test', email_verified: true, email_mutation_pending: false, + first_name: 'SSO', last_name: 'Admin', grant_id: null, + }], rowCount: 1, }); } diff --git a/server/tests/unit/authorization-epoch-session.test.ts b/server/tests/unit/authorization-epoch-session.test.ts index 0ad33ee6e2a..47197352e78 100644 --- a/server/tests/unit/authorization-epoch-session.test.ts +++ b/server/tests/unit/authorization-epoch-session.test.ts @@ -1,161 +1,647 @@ -/** - * Persisted authorization epoch enforcement (#6827). - * - * The session cache is per-instance, so evicting it cannot revoke authority - * granted before an identity-binding change — another instance still serves - * its own cached entry. These tests pin the replacement: every cache hit - * revalidates the persisted epoch fingerprint stamped when the entry was - * stored, and a moved fingerprint forces full re-validation. - */ - +/** Provider caches must never become identity, organization, or epoch authority. */ import type { NextFunction, Request, Response } from 'express'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import supertest from 'supertest'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; const mocks = vi.hoisted(() => ({ - authenticate: vi.fn(), - loadSealedSession: vi.fn(), - checkPlatformBan: vi.fn(), - checkPlatformBanForApiKey: vi.fn(), - getAuthorizationFingerprint: vi.fn(), - poolQuery: vi.fn(), + authenticate: vi.fn(), loadSealedSession: vi.fn(), checkPlatformBan: vi.fn(), + loadAuthorizationSnapshot: vi.fn(), verifyWorkOSJWT: vi.fn(), poolQuery: vi.fn(), + refresh: vi.fn(), getRefreshedSession: vi.fn(), })); - vi.hoisted(() => { - process.env.WORKOS_API_KEY = process.env.WORKOS_API_KEY ?? 'sk_test'; - process.env.WORKOS_CLIENT_ID = process.env.WORKOS_CLIENT_ID ?? 'client_test'; - process.env.WORKOS_COOKIE_PASSWORD = - process.env.WORKOS_COOKIE_PASSWORD ?? 'placeholder-cookie-password-32-bytes-min'; + process.env.DEV_USER_EMAIL = ''; + process.env.DEV_USER_ID = ''; + process.env.WORKOS_API_KEY ??= 'sk_test'; + process.env.WORKOS_CLIENT_ID ??= 'client_test'; + process.env.WORKOS_COOKIE_PASSWORD ??= 'placeholder-cookie-password-32-bytes-min'; }); - vi.mock('@workos-inc/node', () => ({ WorkOS: vi.fn(function WorkOS() { return { - userManagement: { - loadSealedSession: mocks.loadSealedSession, - }, + userManagement: { loadSealedSession: mocks.loadSealedSession }, apiKeys: { createValidation: vi.fn() }, }; }), })); - vi.mock('../../src/db/bans-db.js', () => ({ - bansDb: { - checkPlatformBan: mocks.checkPlatformBan, - checkPlatformBanForApiKey: mocks.checkPlatformBanForApiKey, - }, + bansDb: { checkPlatformBan: mocks.checkPlatformBan, checkPlatformBanForApiKey: vi.fn() }, })); - -vi.mock('../../src/db/authorization-epoch-db.js', () => ({ - getAuthorizationFingerprint: mocks.getAuthorizationFingerprint, - bumpAuthorizationEpochs: vi.fn(), +vi.mock('../../src/db/user-authorization-snapshot-db.js', async (importOriginal) => ({ + ...await importOriginal(), + loadAuthorizationSnapshot: mocks.loadAuthorizationSnapshot, +})); +vi.mock('../../src/auth/workos-jwt.js', () => ({ + verifyWorkOSJWT: mocks.verifyWorkOSJWT, + looksLikeJWT: (token: string) => token.split('.').length === 3, })); - vi.mock('../../src/db/client.js', () => ({ - getPool: () => ({ query: mocks.poolQuery }), - query: mocks.poolQuery, - isDatabaseInitialized: () => true, + getPool: () => ({ query: mocks.poolQuery }), query: mocks.poolQuery, isDatabaseInitialized: () => true, })); +vi.mock('../../src/db/session-refresh-db.js', () => ({ + getRefreshedSession: mocks.getRefreshedSession, + storeRefreshedSession: vi.fn().mockResolvedValue(undefined), + cleanExpiredRefreshes: vi.fn().mockResolvedValue(0), +})); +import { AuthorizationSnapshotUnavailableError } from '../../src/db/user-authorization-snapshot-db.js'; +import { invalidateBanCache, optionalAuth, requireAuth, stopAuthTimers } from '../../src/middleware/auth.js'; +import { csrfProtection } from '../../src/middleware/csrf.js'; -import { requireAuth } from '../../src/middleware/auth.js'; - -const SESSION_USER = { - id: 'user_epoch_primary', - email: 'primary@epoch.test', - firstName: 'Epoch', - lastName: 'User', - emailVerified: true, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', +const AUTHENTICATED_ID = 'user_epoch_authenticated'; +const PROVIDER_USER = { + id: AUTHENTICATED_ID, email: 'sam@example.test', firstName: 'Sam', lastName: 'Adeyemi', + emailVerified: true, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }; - -function makeRequest(cookie: string): Request { +let sequence = 0; +function snapshot(overrides: Partial = {}): AuthorizationSnapshot { return { - headers: {}, - cookies: { 'wos-session': cookie }, - path: '/api/me', - originalUrl: '/api/me', - accepts: () => false, + authenticatedUserId: AUTHENTICATED_ID, canonicalUserId: AUTHENTICATED_ID, + identityId: 'identity_epoch', authorizationEpoch: '1', selectedOrganizationId: null, + credential: { email: PROVIDER_USER.email, firstName: 'Sam', lastName: 'Adeyemi', emailVerified: true, emailMutationPending: false }, + credentialGrant: null, ...overrides, + }; +} +function request(token: string, bearer = false): Request { + return { + headers: bearer ? { authorization: `Bearer ${token}` } : {}, + cookies: bearer ? {} : { 'wos-session': token }, query: {}, body: {}, params: {}, + path: '/api/me', originalUrl: '/api/me', accepts: () => false, } as unknown as Request; } - -function makeResponse(): Response { +function response(): Response { return { - cookie: vi.fn(), - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), + cookie: vi.fn(), status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), redirect: vi.fn().mockReturnThis(), } as unknown as Response; } +beforeEach(() => { + vi.clearAllMocks(); + mocks.loadAuthorizationSnapshot.mockReset(); + mocks.loadAuthorizationSnapshot.mockImplementation(async (_id: string, org: string | null) => + snapshot({ selectedOrganizationId: org })); + invalidateBanCache('user', AUTHENTICATED_ID); + mocks.checkPlatformBan.mockResolvedValue({ banned: false }); + mocks.authenticate.mockResolvedValue({ authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'access-token' }); + mocks.refresh.mockReset().mockResolvedValue({ authenticated: false }); + mocks.getRefreshedSession.mockReset().mockResolvedValue(null); + mocks.loadSealedSession.mockReturnValue({ authenticate: mocks.authenticate, refresh: mocks.refresh }); + mocks.verifyWorkOSJWT.mockResolvedValue({ sub: AUTHENTICATED_ID, email: PROVIDER_USER.email, isM2M: false }); +}); -describe('persisted authorization epoch gates the session cache', () => { +const organizationSelectors: Array<[string, (req: Request, organizationId: string) => void]> = [ + ['header', (req, org) => { req.headers['x-organization-id'] = org; }], + ['query org', (req, org) => { req.query.org = org; }], + ['query organization_id', (req, org) => { req.query.organization_id = org; }], + ['body organization_id', (req, org) => { req.body.organization_id = org; }], + ['body organizationId', (req, org) => { req.body.organizationId = org; }], + ['path orgId', (req, org) => { req.params.orgId = org; }], + ['path organizationId', (req, org) => { req.params.organizationId = org; }], +]; + +describe.each([ + ['required cookie', requireAuth, false], ['optional cookie', optionalAuth, false], + ['required bearer', requireAuth, true], ['optional bearer', optionalAuth, true], +] as const)('%s provider organization binding', (_label, middleware, bearer) => { beforeEach(() => { - vi.clearAllMocks(); - mocks.checkPlatformBan.mockResolvedValue({ banned: false, ban: null }); - // Local user lookup resolves; attachIdentityId sees a singleton identity - // (primary === authenticated credential), so there is no id-swap. - mocks.poolQuery.mockResolvedValue({ - rows: [ - { - first_name: 'Epoch', - last_name: 'User', - identity_id: 'identity_epoch', - primary_workos_user_id: 'user_epoch_primary', - }, - ], - rowCount: 1, + mocks.authenticate.mockResolvedValue({ + authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'access-token', organizationId: 'org_pinnacle', }); + mocks.verifyWorkOSJWT.mockResolvedValue({ + sub: AUTHENTICATED_ID, email: PROVIDER_USER.email, isM2M: false, orgId: 'org_pinnacle', + }); + }); + + describe.each(['cold', 'warm'] as const)('%s cache', (cacheState) => { + it.each(organizationSelectors)('rejects a mismatched %s selector and permits a matching selector', async (_location, select) => { + for (const selectedOrg of ['org_streamhaus', 'org_pinnacle']) { + const token = bearer ? `header.org${++sequence}.signature` : `sealed-org-${++sequence}`; + if (cacheState === 'warm') await middleware(request(token, bearer), response(), vi.fn()); + const req = request(token, bearer); + select(req, selectedOrg); + const res = response(); + const next = vi.fn(); + const snapshotsBefore = mocks.loadAuthorizationSnapshot.mock.calls.length; + await middleware(req, res, next); + if (selectedOrg === 'org_streamhaus') { + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenCalledTimes(snapshotsBefore); + } else { + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + expect(req.user?.authorizationSnapshot?.selectedOrganizationId).toBe('org_pinnacle'); + } + } + }); + }); + + it.each(['', ' ', null, [], {}])('rejects malformed supplied provider organization %j', async (providerOrganization) => { mocks.authenticate.mockResolvedValue({ - authenticated: true, - user: SESSION_USER, - accessToken: 'access-token', + authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'access-token', organizationId: providerOrganization, }); - mocks.loadSealedSession.mockReturnValue({ - authenticate: mocks.authenticate, - refresh: vi.fn(), + mocks.verifyWorkOSJWT.mockResolvedValue({ + sub: AUTHENTICATED_ID, email: PROVIDER_USER.email, isM2M: false, orgId: providerOrganization, }); - mocks.getAuthorizationFingerprint.mockResolvedValue('user_epoch_primary:1'); + const token = bearer ? `header.malformed${++sequence}.signature` : `sealed-malformed-${++sequence}`; + const req = request(token, bearer); + req.headers['x-organization-id'] = 'org_pinnacle'; + const res = response(); + const next = vi.fn(); + await middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); }); +}); - it('serves the cached session while the fingerprint is unchanged', async () => { - const cookie = `sealed-unchanged-${Date.now()}`; - const next = vi.fn() as NextFunction; +describe('optional authentication credential presence', () => { + function expectRejected(req: Request, res: Response, next: ReturnType, status: number): void { + expect(res.status).toHaveBeenCalledWith(status); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + expect(res.redirect).not.toHaveBeenCalled(); + } - await requireAuth(makeRequest(cookie), makeResponse(), next); - await requireAuth(makeRequest(cookie), makeResponse(), next); + it('permits anonymous access only when no credential is supplied', async () => { + const req = request('unused'); + req.cookies = {}; + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expect(next).toHaveBeenCalledOnce(); + expect(req.user).toBeUndefined(); + expect(res.status).not.toHaveBeenCalled(); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }); - expect(next).toHaveBeenCalledTimes(2); - // Second request came from cache — the sealed session was unsealed once. - expect(mocks.authenticate).toHaveBeenCalledTimes(1); + it.each([false, true])('rejects a failed JWT without anonymous or cookie fallback (cookie present: %s)', async (cookiePresent) => { + const req = request(`header.invalid${++sequence}.signature`, true); + if (cookiePresent) req.cookies['wos-session'] = 'otherwise-valid-cookie'; + mocks.verifyWorkOSJWT.mockRejectedValue(new Error('Invalid JWT signature')); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); }); - it('re-validates when an identity-binding change moved the fingerprint', async () => { - const cookie = `sealed-bumped-${Date.now()}`; - const next = vi.fn() as NextFunction; + it.each(['Bearer ', 'Bearer ', 'Basic invalid', ''])('rejects malformed authorization header %j without cookie fallback', async (header) => { + const req = request('otherwise-valid-cookie'); + req.headers.authorization = header; + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + }); + + it('rejects an unrecognized bearer after sealed-session validation instead of using a valid cookie', async () => { + const token = `invalid-opaque-${++sequence}`; + const req = request(token, true); + req.cookies['wos-session'] = 'otherwise-valid-cookie'; + mocks.authenticate.mockResolvedValue({ authenticated: false }); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); + expect(mocks.loadSealedSession).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sessionData: token })); + }); + + it('continues to authenticate native sealed bearer sessions', async () => { + const token = `native-sealed-${++sequence}`; + const req = request(token, true); + const next = vi.fn(); + await optionalAuth(req, response(), next); + expect(next).toHaveBeenCalledOnce(); + expect(mocks.loadSealedSession).toHaveBeenCalledWith(expect.objectContaining({ sessionData: token })); + expect(req.user?.authorizationSnapshot?.authenticatedUserId).toBe(AUTHENTICATED_ID); + }); - await requireAuth(makeRequest(cookie), makeResponse(), next); - expect(mocks.authenticate).toHaveBeenCalledTimes(1); + it.each(['', null, {}])('rejects malformed supplied session cookie %j', async (cookie) => { + const req = request('unused'); + req.cookies['wos-session'] = cookie; + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + }); - // A binding mutation bumped the credential's epoch on another instance. - mocks.getAuthorizationFingerprint.mockResolvedValue('user_epoch_primary:2'); + it('rejects expired sessions and their dead-cache replay instead of making them anonymous', async () => { + const token = `dead-session-${++sequence}`; + mocks.authenticate.mockResolvedValue({ authenticated: false }); + for (let attempt = 0; attempt < 2; attempt += 1) { + const req = request(token); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); + } + expect(mocks.authenticate).toHaveBeenCalledOnce(); + expect(mocks.refresh).toHaveBeenCalledOnce(); + }); - await requireAuth(makeRequest(cookie), makeResponse(), next); + it('rejects a provider result claiming authentication without a user', async () => { + mocks.authenticate.mockResolvedValue({ authenticated: true }); + const req = request(`missing-user-${++sequence}`); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); + }); - expect(next).toHaveBeenCalledTimes(2); - expect(mocks.authenticate).toHaveBeenCalledTimes(2); + it.each([false, true])('rejects a missing local credential on cold authentication (bearer: %s)', async (bearer) => { + mocks.loadAuthorizationSnapshot.mockResolvedValue(null); + const req = request(bearer ? `header.missing${++sequence}.signature` : `sealed-missing-${++sequence}`, bearer); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); }); - it('bypasses the cache when the epoch lookup fails rather than serving unconfirmed state', async () => { - const cookie = `sealed-lookup-error-${Date.now()}`; - const next = vi.fn() as NextFunction; + it.each(['JWT verification', 'session authentication', 'session refresh'] as const)('returns 503 on transient %s failure without anonymous fallback', async (stage) => { + const transient = Object.assign(new Error('Authentication upstream timed out'), { code: 'ETIMEDOUT' }); + const bearer = stage === 'JWT verification'; + const req = request(bearer ? `header.timeout${++sequence}.signature` : `sealed-timeout-${++sequence}`, bearer); + if (stage === 'JWT verification') mocks.verifyWorkOSJWT.mockRejectedValue(transient); + if (stage === 'session authentication') mocks.authenticate.mockRejectedValue(transient); + if (stage === 'session refresh') { + mocks.authenticate.mockResolvedValue({ authenticated: false }); + mocks.refresh.mockRejectedValue(transient); + } + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 503); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }); + + it('returns 503 if shared session recovery is unavailable', async () => { + mocks.authenticate.mockResolvedValue({ authenticated: false }); + mocks.getRefreshedSession.mockRejectedValue(new Error('Session database unavailable')); + const req = request(`sealed-recovery-${++sequence}`); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 503); + }); + + it.each([ + ['JWKS HTTP response', Object.assign(new Error('Expected 200 OK from the JSON Web Key Set HTTP response'), { + name: 'JOSEError', code: 'ERR_JOSE_GENERIC', + })], + ['malformed JWKS response', Object.assign(new Error('Failed to parse the JSON Web Key Set HTTP response as JSON'), { + name: 'JOSEError', code: 'ERR_JOSE_GENERIC', + })], + ['provider HTTP 503', Object.assign(new Error('Service unavailable'), { status: 503 })], + ['provider HTTP 429', Object.assign(new Error('Rate limited'), { statusCode: 429 })], + ])('returns 503 for unavailable %s during JWT verification', async (_label, error) => { + mocks.verifyWorkOSJWT.mockRejectedValue(error); + const req = request(`header.unavailable${++sequence}.signature`, true); + req.cookies['wos-session'] = 'otherwise-valid-cookie'; + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 503); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + }); + + it.each(['initial', 'shared'] as const)('returns 503 for a retryable %s refresh result and permits a later retry', async (stage) => { + const token = `retryable-${stage}-${++sequence}`; + mocks.authenticate.mockResolvedValue({ authenticated: false }); + if (stage === 'shared') { + mocks.getRefreshedSession.mockResolvedValueOnce('shared-sealed-session'); + mocks.refresh.mockResolvedValueOnce({ authenticated: false, reason: 'invalid_grant', retryable: false }); + } + mocks.refresh.mockResolvedValue({ authenticated: false, reason: 'server_error', retryable: true }); + const req = request(token); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 503); - await requireAuth(makeRequest(cookie), makeResponse(), next); - expect(mocks.authenticate).toHaveBeenCalledTimes(1); + mocks.authenticate.mockResolvedValue({ authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'access-token' }); + const retry = request(token); + const retryNext = vi.fn(); + await optionalAuth(retry, response(), retryNext); + expect(retryNext).toHaveBeenCalledOnce(); + expect(retry.user?.authorizationSnapshot?.authenticatedUserId).toBe(AUTHENTICATED_ID); + }); - mocks.getAuthorizationFingerprint.mockRejectedValueOnce(new Error('db down')); + it('returns 503 on a thrown provider HTTP 503 during session refresh', async () => { + mocks.authenticate.mockResolvedValue({ authenticated: false }); + mocks.refresh.mockRejectedValue(Object.assign(new Error('Service unavailable'), { status: 503 })); + const req = request(`refresh-503-${++sequence}`); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 503); + }); - await requireAuth(makeRequest(cookie), makeResponse(), next); + it('returns 401 when sealed-session decoding rejects the presented credential', async () => { + mocks.loadSealedSession.mockImplementationOnce(() => { throw new Error('Invalid sealed session'); }); + const req = request(`invalid-sealed-${++sequence}`); + const res = response(); + const next = vi.fn(); + await optionalAuth(req, res, next); + expectRejected(req, res, next, 401); + }); +}); +afterAll(stopAuthTimers); +describe.each([ + ['required cookie', requireAuth, false], ['optional cookie', optionalAuth, false], + ['required bearer', requireAuth, true], ['optional bearer', optionalAuth, true], +] as const)('%s authorization snapshots', (_label, middleware, bearer) => { + function credential(): string { + sequence += 1; + return bearer ? `header.payload${sequence}.signature` : `sealed-snapshot-${sequence}`; + } + it('hydrates an atomic snapshot on cold start and every cached request', async () => { + const token = credential(); + const first = request(token, bearer); + const second = request(token, bearer); + const next = vi.fn() as NextFunction; + await middleware(first, response(), next); + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ authorizationEpoch: '2' })); + await middleware(second, response(), next); expect(next).toHaveBeenCalledTimes(2); - expect(mocks.authenticate).toHaveBeenCalledTimes(2); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenCalledTimes(2); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenNthCalledWith(2, AUTHENTICATED_ID, null); + expect(first.user?.authorizationSnapshot?.authorizationEpoch).toBe('1'); + expect(second.user?.authorizationSnapshot?.authorizationEpoch).toBe('2'); + expect(bearer ? mocks.verifyWorkOSJWT : mocks.authenticate).toHaveBeenCalledTimes(1); + expect(mocks.poolQuery).not.toHaveBeenCalled(); + }); + it.each([ + ['secondary becomes primary', 'user_other_primary', AUTHENTICATED_ID], + ['primary becomes secondary', AUTHENTICATED_ID, 'user_other_primary'], + ])('keeps the exact credential when %s', async (_direction, before, after) => { + const token = credential(); + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ canonicalUserId: before })); + const first = request(token, bearer); + await middleware(first, response(), vi.fn()); + // A handler mutates its request object, which must never poison provider authentication. + first.user!.id = 'user_attacker_canonical'; + first.user!.authWorkosUserId = 'user_attacker_credential'; + first.user!.identityId = 'identity_attacker'; + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ + canonicalUserId: after, identityId: 'identity_changed', authorizationEpoch: '2', + })); + const second = request(token, bearer); + const next = vi.fn(); + await middleware(second, response(), next); + expect(next).toHaveBeenCalledOnce(); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenLastCalledWith(AUTHENTICATED_ID, null); + expect(second.user).toMatchObject({ id: after, identityId: 'identity_changed' }); + expect(second.user?.authWorkosUserId ?? second.user?.id).toBe(AUTHENTICATED_ID); + expect(second.user?.authorizationSnapshot?.authorizationEpoch).toBe('2'); + }); + it.each(['cold', 'warm'] as const)('returns unavailable with no fallback on a %s-cache database outage', async (cacheState) => { + const token = credential(); + if (cacheState === 'warm') await middleware(request(token, bearer), response(), vi.fn()); + mocks.loadAuthorizationSnapshot.mockRejectedValue(new AuthorizationSnapshotUnavailableError()); + const req = request(token, bearer); + const res = response(); + const next = vi.fn(); + await middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + expect(res.redirect).not.toHaveBeenCalled(); + expect(bearer ? mocks.verifyWorkOSJWT : mocks.authenticate).toHaveBeenCalledTimes(1); + }); + it('rejects a deleted exact credential instead of recovering its canonical user', async () => { + const token = credential(); + await middleware(request(token, bearer), response(), vi.fn()); + mocks.loadAuthorizationSnapshot.mockResolvedValue(null); + const req = request(token, bearer); + const res = response(); + const next = vi.fn(); + await middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + expect(req.user).toBeUndefined(); + }); + it('keeps organization selection request-local across cache hits and leaves no selection empty', async () => { + const token = credential(); + for (const org of ['org_pinnacle', 'org_streamhaus', null]) { + const req = request(token, bearer); + if (org) req.headers['x-organization-id'] = org; + await middleware(req, response(), vi.fn()); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenLastCalledWith(AUTHENTICATED_ID, org); + expect(req.user?.authorizationSnapshot?.selectedOrganizationId).toBe(org); + } + expect(bearer ? mocks.verifyWorkOSJWT : mocks.authenticate).toHaveBeenCalledTimes(1); + }); + it('uses current local credential email instead of a stale provider email', async () => { + const token = credential(); + await middleware(request(token, bearer), response(), vi.fn()); + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ + credential: { email: 'sam-current@example.test', firstName: 'Sam', lastName: 'Adeyemi', emailVerified: true, emailMutationPending: false }, + })); + const req = request(token, bearer); + await middleware(req, response(), vi.fn()); + expect(req.user?.email).toBe('sam-current@example.test'); + }); + it('does not apply an old provider verification to the current unverified email', async () => { + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ + credential: { email: 'sam-unverified@example.test', firstName: 'Sam', lastName: 'Adeyemi', emailVerified: false }, + })); + const req = request(credential(), bearer); + await middleware(req, response(), vi.fn()); + expect(req.user).toMatchObject({ email: 'sam-unverified@example.test', emailVerified: false }); + }); + it('rejects conflicting explicit organization selectors before hydrating authority', async () => { + const req = request(credential(), bearer); + req.headers['x-organization-id'] = 'org_pinnacle'; + req.params.orgId = 'org_streamhaus'; + const res = response(); + const next = vi.fn(); + await middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }); + it('keeps authorization provenance out of serialized and spread user objects', async () => { + const req = request(credential(), bearer); + await middleware(req, response(), vi.fn()); + expect(req.user?.authorizationSnapshot?.authenticatedUserId).toBe(AUTHENTICATED_ID); + expect(JSON.stringify(req.user)).not.toContain('authorizationSnapshot'); + expect({ ...req.user }).not.toHaveProperty('authorizationSnapshot'); + }); +}); +it('a cookie cached by optional auth is rehydrated before required auth', async () => { + const token = `shared-optional-${++sequence}`; + await optionalAuth(request(token), response(), vi.fn()); + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ canonicalUserId: 'user_changed', authorizationEpoch: '7' })); + const second = request(token); + await requireAuth(second, response(), vi.fn()); + expect(mocks.authenticate).toHaveBeenCalledTimes(1); + expect(second.user).toMatchObject({ id: 'user_changed', authWorkosUserId: AUTHENTICATED_ID }); + expect(second.user?.authorizationSnapshot?.authorizationEpoch).toBe('7'); +}); + +describe.each([['required', requireAuth], ['optional', optionalAuth]] as const)('%s bearer bans', (_label, middleware) => { + it.each(['cold', 'warm'] as const)('checks the banned exact linked credential on a %s cache', async (cacheState) => { + const token = `header.banned${++sequence}.signature`; + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ canonicalUserId: 'user_unbanned_primary' })); + if (cacheState === 'warm') { + await middleware(request(token, true), response(), vi.fn()); + invalidateBanCache('user', AUTHENTICATED_ID); + } + mocks.checkPlatformBan.mockImplementation(async (userId: string) => userId === AUTHENTICATED_ID + ? { banned: true, ban: { id: 'ban_exact_credential', reason: 'Account suspended' } } + : { banned: false }); + const res = response(); + const next = vi.fn(); + await middleware(request(token, true), res, next); + expect(mocks.checkPlatformBan).toHaveBeenLastCalledWith(AUTHENTICATED_ID); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); +}); + +describe.each([['required', requireAuth], ['optional', optionalAuth]] as const)('%s concurrent bearer hydration', (_label, middleware) => { + it('keeps overlapping cached JWT requests bound to the original authenticated credential', async () => { + const token = `header.overlapping${++sequence}.signature`; + await middleware(request(token, true), response(), vi.fn()); + + let resolveFirst!: (value: AuthorizationSnapshot) => void; + let resolveSecond!: (value: AuthorizationSnapshot) => void; + let markFirstStarted!: () => void; + let markSecondStarted!: () => void; + const firstStarted = new Promise((resolve) => { markFirstStarted = resolve; }); + const secondStarted = new Promise((resolve) => { markSecondStarted = resolve; }); + const firstSnapshot = snapshot({ canonicalUserId: 'user_primary_a' }); + const secondSnapshot = snapshot({ canonicalUserId: 'user_primary_b', authorizationEpoch: '2' }); + mocks.loadAuthorizationSnapshot + .mockImplementationOnce(() => { + markFirstStarted(); + return new Promise((resolve) => { resolveFirst = resolve; }); + }) + .mockImplementationOnce(() => { + markSecondStarted(); + return new Promise((resolve) => { resolveSecond = resolve; }); + }) + .mockResolvedValue(secondSnapshot); + + const first = request(token, true); + const second = request(token, true); + const firstNext = vi.fn(); + const secondNext = vi.fn(); + const firstPending = middleware(first, response(), firstNext); + const secondPending = middleware(second, response(), secondNext); + await Promise.all([firstStarted, secondStarted]); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenNthCalledWith(2, AUTHENTICATED_ID, null); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenNthCalledWith(3, AUTHENTICATED_ID, null); + + resolveFirst(firstSnapshot); + await firstPending; + const firstUser = first.user; + expect(firstUser).toMatchObject({ id: 'user_primary_a', authWorkosUserId: AUTHENTICATED_ID }); + expect(firstUser?.authorizationSnapshot).toBe(firstSnapshot); + expect(secondNext).not.toHaveBeenCalled(); + + resolveSecond(secondSnapshot); + await secondPending; + expect(firstNext).toHaveBeenCalledOnce(); + expect(secondNext).toHaveBeenCalledOnce(); + expect(second.user).not.toBe(firstUser); + expect(second.user).toMatchObject({ id: 'user_primary_b', authWorkosUserId: AUTHENTICATED_ID }); + expect(second.user?.authorizationSnapshot).toBe(secondSnapshot); + expect(first.user).toBe(firstUser); + expect(first.user).toMatchObject({ id: 'user_primary_a', authWorkosUserId: AUTHENTICATED_ID }); + expect(first.user?.authorizationSnapshot).toBe(firstSnapshot); + + const following = request(token, true); + await middleware(following, response(), vi.fn()); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenNthCalledWith(4, AUTHENTICATED_ID, null); + expect(following.user?.authorizationSnapshot?.authenticatedUserId).toBe(AUTHENTICATED_ID); + expect(following.user?.authWorkosUserId).toBe(AUTHENTICATED_ID); + expect(mocks.verifyWorkOSJWT).toHaveBeenCalledTimes(1); + }); +}); + +describe('optional authentication HTTP route boundary', () => { + function route(parsedCookies: Record = {}) { + const app = express(); + // Match network-health-security.test.ts: authentication is under test, not + // cookie-parser. Inject its parsed output from a fixture owned by this app; + // the HTTP requests below send the corresponding Cookie header. An absent + // cookie remains {}, while a presented empty cookie retains its empty value. + app.use((req, _res, next) => { + req.cookies = { ...parsedCookies }; + next(); + }); + app.use(csrfProtection); + const handler = vi.fn((req: Request, res: Response) => { + res.json({ authenticated: Boolean(req.user) }); + }); + app.get('/optional-auth-test', optionalAuth, handler); + return { app, handler }; + } + + it('reaches the anonymous handler when neither Authorization nor Cookie is sent', async () => { + const { app, handler } = route(); + const result = await supertest(app).get('/optional-auth-test'); + expect(result.status).toBe(200); + expect(result.body).toEqual({ authenticated: false }); + expect(handler).toHaveBeenCalledOnce(); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }); + + it.each(['', 'Basic invalid', 'Bearer header.invalid.signature'])('rejects supplied Authorization %j without using an otherwise valid cookie', async (authorization) => { + mocks.verifyWorkOSJWT.mockRejectedValue(new Error('Invalid JWT signature')); + const cookie = `otherwise-valid-${++sequence}`; + const { app, handler } = route({ 'wos-session': cookie }); + const result = await supertest(app).get('/optional-auth-test') + .set('Authorization', authorization) + .set('Cookie', `wos-session=${cookie}`); + expect(result.status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + }); + + it('rejects an opaque invalid bearer after validating that bearer rather than its companion cookie', async () => { + mocks.authenticate.mockResolvedValue({ authenticated: false }); + const token = `invalid-native-route-${++sequence}`; + const { app, handler } = route({ 'wos-session': 'otherwise-valid' }); + const result = await supertest(app).get('/optional-auth-test') + .set('Authorization', `Bearer ${token}`) + .set('Cookie', 'wos-session=otherwise-valid'); + expect(result.status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + expect(mocks.loadSealedSession).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sessionData: token })); + }); + + it.each(['empty', 'invalid'] as const)('rejects an %s presented wos-session cookie before the handler', async (kind) => { + mocks.authenticate.mockResolvedValue({ authenticated: false }); + const token = kind === 'empty' ? '' : `invalid-cookie-route-${++sequence}`; + const { app, handler } = route({ 'wos-session': token }); + const result = await supertest(app).get('/optional-auth-test').set('Cookie', `wos-session=${token}`); + expect(result.status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + if (kind === 'empty') expect(mocks.loadSealedSession).not.toHaveBeenCalled(); + }); + + it.each(['bearer', 'cookie'] as const)('returns 503 for unavailable authorization on a presented %s without running the handler', async (kind) => { + mocks.loadAuthorizationSnapshot.mockRejectedValue(new AuthorizationSnapshotUnavailableError()); + const cookie = `unavailable-route-${++sequence}`; + const { app, handler } = route(kind === 'cookie' ? { 'wos-session': cookie } : {}); + const httpRequest = supertest(app).get('/optional-auth-test'); + if (kind === 'bearer') httpRequest.set('Authorization', `Bearer header.route${++sequence}.signature`); + else httpRequest.set('Cookie', `wos-session=${cookie}`); + const result = await httpRequest; + expect(result.status).toBe(503); + expect(handler).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenCalledOnce(); }); }); diff --git a/server/tests/unit/db-client-checkout.test.ts b/server/tests/unit/db-client-checkout.test.ts index b813b903fb6..2a8fb586649 100644 --- a/server/tests/unit/db-client-checkout.test.ts +++ b/server/tests/unit/db-client-checkout.test.ts @@ -311,6 +311,117 @@ describe('db client checkout and health checks', () => { await db.closeDatabase(); }); + it('fails an exhausted snapshot pool at the absolute deadline and releases a late client', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const pg = mockPg(); + let deliverClient!: (client: { query: ReturnType; release: ReturnType }) => void; + pg.poolConnect.mockReturnValue(new Promise((resolve) => { + deliverClient = resolve; + })); + + const db = await import('../../src/db/client.js'); + const snapshots = await import('../../src/db/user-authorization-snapshot-db.js'); + db.initializeDatabase({ connectionString: 'postgresql://localhost/test' }); + + const pending = snapshots.loadAuthorizationSnapshot('user_exhausted_pool', 'org_exhausted_pool'); + const rejection = expect(pending).rejects.toBeInstanceOf( + snapshots.AuthorizationSnapshotUnavailableError, + ); + await vi.advanceTimersByTimeAsync(1_999); + expect(pg.poolConnect).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await rejection; + + const lateClient = { query: vi.fn(), release: vi.fn() }; + deliverClient(lateClient); + await Promise.resolve(); + await Promise.resolve(); + expect(lateClient.query).not.toHaveBeenCalled(); + expect(lateClient.release).toHaveBeenCalledTimes(1); + expect(pg.poolConnect).toHaveBeenCalledTimes(1); + + await db.closeDatabase(); + }); + + it('retries one transient snapshot checkout with only the remaining absolute budget', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const pg = mockPg(); + const release = vi.fn(); + const query = vi.fn().mockImplementation(async (text: string) => { + if (text.includes('pg_catalog.pg_is_in_recovery')) { + return { + rows: [{ + in_recovery: false, + authenticated_user_id: 'user_retry_checkout', + canonical_user_id: 'user_retry_checkout', + identity_id: 'fd3043f7-cb4f-43c7-9b81-22ac97576150', + authorization_epoch: '11', + email: 'sam@pinnacle.example', + email_verified: true, + email_mutation_pending: false, + first_name: 'Sam', + last_name: 'Adeyemi', + grant_id: null, + grant_organization_id: null, + grant_role: null, + grant_effective_from: null, + grant_effective_until: null, + }], + }; + } + return { rows: [] }; + }); + pg.poolConnect + .mockImplementationOnce(async () => { + vi.setSystemTime(Date.now() + 700); + throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + }) + .mockResolvedValueOnce({ query, release }); + + const db = await import('../../src/db/client.js'); + const snapshots = await import('../../src/db/user-authorization-snapshot-db.js'); + db.initializeDatabase({ connectionString: 'postgresql://localhost/test' }); + + await expect(snapshots.loadAuthorizationSnapshot('user_retry_checkout', 'org_retry_checkout')) + .resolves.toMatchObject({ + authenticatedUserId: 'user_retry_checkout', + selectedOrganizationId: 'org_retry_checkout', + authorizationEpoch: '11', + }); + + expect(pg.poolConnect).toHaveBeenCalledTimes(2); + expect(query).toHaveBeenCalledWith( + "SELECT set_config('statement_timeout', $1, true)", + ['1300ms'], + ); + expect(release).toHaveBeenCalledTimes(1); + + await db.closeDatabase(); + }); + + it('does not multiply snapshot retries inside pool checkout', async () => { + const pg = mockPg(); + const unexpectedClient = { query: vi.fn(), release: vi.fn() }; + pg.poolConnect + .mockRejectedValueOnce(Object.assign(new Error('first reset'), { code: 'ECONNRESET' })) + .mockRejectedValueOnce(Object.assign(new Error('second reset'), { code: 'ECONNRESET' })) + .mockResolvedValueOnce(unexpectedClient); + + const db = await import('../../src/db/client.js'); + const snapshots = await import('../../src/db/user-authorization-snapshot-db.js'); + db.initializeDatabase({ connectionString: 'postgresql://localhost/test' }); + + await expect(snapshots.loadAuthorizationSnapshot('user_two_attempts', 'org_two_attempts')) + .rejects.toBeInstanceOf(snapshots.AuthorizationSnapshotUnavailableError); + expect(pg.poolConnect).toHaveBeenCalledTimes(2); + expect(unexpectedClient.query).not.toHaveBeenCalled(); + expect(unexpectedClient.release).not.toHaveBeenCalled(); + + await db.closeDatabase(); + }); + it('rolls back and releases the client after a timed query fails', async () => { const pg = mockPg(); const release = vi.fn(); diff --git a/server/tests/unit/email-mutation-lock.test.ts b/server/tests/unit/email-mutation-lock.test.ts new file mode 100644 index 00000000000..e7014adf20a --- /dev/null +++ b/server/tests/unit/email-mutation-lock.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ query: vi.fn(), poolQuery: vi.fn(), release: vi.fn(), connect: vi.fn(), log: vi.fn() })); +vi.mock('../../src/db/client.js', () => ({ getPool: () => ({ connect: mocks.connect, query: mocks.poolQuery }), getDedicatedClient: vi.fn() })); +vi.mock('../../src/logger.js', () => ({ createLogger: () => ({ error: mocks.log, warn: mocks.log, info: mocks.log }) })); +vi.mock('../../src/auth/workos-client.js', () => ({ getEmailMutationWorkos: vi.fn() })); +import { setPrimaryEmail } from '../../src/services/email-mutation.js'; +const operationId = '9f49dcb7-c1d6-48b9-9a55-c14339f7e63e'; +const attempt = () => setPrimaryEmail({ userId: 'user_test', operationId, email: 'old@test.example' }).catch(error => error); +const noRows = { rowCount: 0, rows: [] }; +const userRow = { rowCount: 1, rows: [{ workos_user_id: 'user_test', email: 'old@test.example', email_verified: false, email_mutation_version: '0' }] }; + +describe('email mutation lock and request validation', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.connect.mockResolvedValue({ query: mocks.query, release: mocks.release }); + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes('pg_try_advisory_lock')) return { rows: [{ locked: true }] }; + if (sql.includes('pg_advisory_unlock')) return { rows: [{ unlocked: true }] }; + if (sql.includes('FROM email_mutations')) return noRows; + if (sql.includes('FROM users')) return userRow; + throw new Error('Unexpected query'); + }); + }); + it('releases the connection only after positively acknowledging unlock and never nests pool checkout', async () => { + expect(await attempt()).toMatchObject({ status: 400 }); + expect(mocks.release).toHaveBeenCalledExactlyOnceWith(false); expect(mocks.poolQuery).not.toHaveBeenCalled(); + }); + it('rejects a database row for a different credential before recording an intent', async () => { + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes('pg_try_advisory_lock')) return { rows: [{ locked: true }] }; + if (sql.includes('pg_advisory_unlock')) return { rows: [{ unlocked: true }] }; + if (sql.includes('FROM users')) return { rowCount: 1, rows: [{ ...userRow.rows[0], workos_user_id: 'user_other' }] }; + return noRows; + }); + expect(await attempt()).toMatchObject({ message: 'Credential row does not match requested user' }); + expect(mocks.query.mock.calls.some(([sql]) => /INSERT|UPDATE/.test(sql))).toBe(false); + }); + it.each([false, undefined])('destroys the connection for unlock result %s', async unlocked => { + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes('pg_try_advisory_lock')) return { rows: [{ locked: true }] }; + if (sql.includes('pg_advisory_unlock')) return { rows: unlocked === undefined ? [] : [{ unlocked }] }; + return sql.includes('FROM users') ? userRow : noRows; + }); + await attempt(); expect(mocks.release).toHaveBeenCalledExactlyOnceWith(true); + }); + it('destroys the connection on unlock failure without logging raw diagnostics', async () => { + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes('pg_try_advisory_lock')) return { rows: [{ locked: true }] }; + if (sql.includes('pg_advisory_unlock')) throw new Error('secret diagnostic'); + return sql.includes('FROM users') ? userRow : noRows; + }); + await attempt(); expect(mocks.release).toHaveBeenCalledExactlyOnceWith(true); + expect(JSON.stringify(mocks.log.mock.calls)).not.toContain('secret'); + }); + it('destroys a connection whose lock acquisition response is lost', async () => { + mocks.query.mockRejectedValueOnce(new Error('acquisition response lost')); + await attempt(); expect(mocks.release).toHaveBeenCalledExactlyOnceWith(true); + }); + it('reports busy as retryable without a nonexistent reconciliation operation', async () => { + mocks.query.mockResolvedValueOnce({ rows: [{ locked: false }] }); + const error = await attempt(); + expect(error).toMatchObject({ status: 409, body: { error: 'credential_busy', retryable: true } }); + expect(error.body).not.toHaveProperty('reconciliation_required'); expect(error.body).not.toHaveProperty('operation_id'); + expect(mocks.release).toHaveBeenCalledExactlyOnceWith(false); expect(mocks.query).toHaveBeenCalledOnce(); + }); + it('destroys a connection with unproven lock ownership', async () => { + mocks.query.mockResolvedValueOnce({ rows: [] }); await attempt(); expect(mocks.release).toHaveBeenCalledExactlyOnceWith(true); + }); + it.each([undefined, null, {}, '', ' ', 'not-an-email', '@test.example', 'a@b@c.example', 'a@.example', 'a@test.', 'a @test.example', `${'a'.repeat(256)}@test.example`])('rejects malformed email %j before database work', async email => { + await expect(setPrimaryEmail({ userId: 'user_test', operationId, email })).rejects.toMatchObject({ status: 400 }); + expect(mocks.connect).not.toHaveBeenCalled(); + }); + it.each([undefined, null, {}, '', 'request-1', '9f49dcb7-c1d6-48b9-9a55-c14339f7e63z'])('requires a caller UUID (%j)', async id => { + await expect(setPrimaryEmail({ userId: 'user_test', operationId: id, email: 'new@test.example' })).rejects.toMatchObject({ status: 400 }); + expect(mocks.connect).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/linked-email-reconciliation-ui.test.ts b/server/tests/unit/linked-email-reconciliation-ui.test.ts new file mode 100644 index 00000000000..44c7da9bd95 --- /dev/null +++ b/server/tests/unit/linked-email-reconciliation-ui.test.ts @@ -0,0 +1,346 @@ +import { readFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { JSDOM } from 'jsdom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const source = readFileSync(new URL('../../public/dashboard-settings.html', import.meta.url), 'utf8'); +const scriptStart = source.indexOf(' var accountPreferences = null;'); +const scriptEnd = source.indexOf(' // ==========================================\n // Notifications', scriptStart); +if (scriptStart < 0 || scriptEnd < 0) throw new Error('Linked email script not found'); +const documents: JSDOM[] = []; +const mutationSelector = '.make-primary-btn, #openLinkEmailBtn, #linkEmailBtn, #linkEmailInput'; +const supportMessage = 'We could not confirm the provider update. Contact support for reconciliation before changing email again.'; + +function response(data: Record, status = 200) { + return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }); +} + +function emailStatus(overrides: Record = {}) { + return { + credential_id: 'user_credential', + primary_email: 'primary@example.test', + aliases: [{ email: 'alias@example.test' }], + pending: [], + reconciliation_required: false, + ...overrides, + }; +} + +function deferredResponse() { + let resolve!: (value: Response) => void; + const promise = new Promise((resolvePromise) => { resolve = resolvePromise; }); + return { promise, resolve }; +} + +function loadControls(savedStorage?: Storage) { + const dom = new JSDOM(source, { url: 'https://agenticadvertising.org/dashboard-settings.html' }); + documents.push(dom); + const document = dom.window.document; + const fetchMock = vi.fn(); + const showToast = vi.fn(); + const confirmMock = vi.fn(() => true); + const sessionStorage = savedStorage ?? dom.window.sessionStorage; + const newOperationId = vi.fn(randomUUID); + const functions = new Function('document', 'window', 'fetch', 'confirm', ` + ${source.slice(scriptStart, scriptEnd)} + return { loadLinkedEmails, setPrimaryEmail, openLinkEmailModal, sendLinkVerification }; + `)(document, { + sessionStorage, + crypto: { randomUUID: newOperationId }, + ProfileEdit: { + showToast, + escapeHtml(value: string) { + const span = document.createElement('span'); + span.textContent = value; + return span.innerHTML; + }, + }, + }, fetchMock, confirmMock) as { + loadLinkedEmails: () => Promise; + setPrimaryEmail: (email: string) => Promise; + openLinkEmailModal: () => void; + sendLinkVerification: () => Promise; + }; + + function expectDisabled(disabled: boolean) { + const controls = document.querySelectorAll(mutationSelector); + expect(controls.length).toBeGreaterThanOrEqual(3); + for (const control of controls) expect(control.disabled, control.id || control.className).toBe(disabled); + } + + async function loadReady() { + fetchMock.mockResolvedValueOnce(response(emailStatus())); + await functions.loadLinkedEmails(); + expectDisabled(false); + } + + function enterEmail() { + functions.openLinkEmailModal(); + (document.getElementById('linkEmailInput') as HTMLInputElement).value = 'new@example.test'; + } + + return { document, fetchMock, showToast, confirmMock, sessionStorage, newOperationId, expectDisabled, loadReady, enterEmail, ...functions }; +} + +afterEach(() => { + for (const dom of documents.splice(0)) dom.window.close(); +}); + +describe('linked email reconciliation UI', () => { + it('persists a caller operation before sending and reuses it after a lost response and reload', async () => { + const first = loadControls(); + await first.loadReady(); + first.fetchMock.mockImplementationOnce(async (_url, options) => { + const sent = JSON.parse(options.body); + expect(JSON.parse(first.sessionStorage.getItem('member-primary-email:user_credential')!)) + .toEqual({ email: 'alias@example.test', operation_id: sent.operation_id }); + throw new Error('Response lost'); + }); + await first.setPrimaryEmail('alias@example.test'); + first.expectDisabled(true); + const original = JSON.parse(first.fetchMock.mock.calls[1][1].body); + + const reload = loadControls(first.sessionStorage); + await reload.loadReady(); + reload.fetchMock.mockResolvedValueOnce(response({ primary_email: 'alias@example.test', operation_id: original.operation_id })) + .mockResolvedValueOnce(response(emailStatus({ primary_email: 'alias@example.test' }))); + await reload.setPrimaryEmail('alias@example.test'); + expect(JSON.parse(reload.fetchMock.mock.calls[1][1].body)).toEqual(original); + expect(reload.newOperationId).not.toHaveBeenCalled(); + expect(first.sessionStorage.getItem('member-primary-email:user_credential')).toBeNull(); + }); + + it('keeps lock contention retryable with the same caller operation instead of entering reconciliation', async () => { + const controls = loadControls(); + await controls.loadReady(); + controls.fetchMock.mockResolvedValueOnce(response({ error: 'credential_busy', retryable: true, message: 'Please retry.' }, 409)); + await controls.setPrimaryEmail('alias@example.test'); + controls.expectDisabled(false); + const original = JSON.parse(controls.fetchMock.mock.calls[1][1].body); + controls.fetchMock.mockResolvedValueOnce(response({ primary_email: 'alias@example.test', operation_id: original.operation_id })) + .mockResolvedValueOnce(response(emailStatus())); + await controls.setPrimaryEmail('alias@example.test'); + expect(JSON.parse(controls.fetchMock.mock.calls[2][1].body)).toEqual(original); + expect(controls.newOperationId).toHaveBeenCalledOnce(); + }); + + it.each([409, 502, 503])('starts a new caller operation after a stored terminal failure (%i)', async (status) => { + const controls = loadControls(); + await controls.loadReady(); + controls.fetchMock.mockResolvedValueOnce(response({ + error: 'Email change failed', message: 'Your email was not changed. Start a new request.', reconciliation_required: false, + }, status)); + await controls.setPrimaryEmail('alias@example.test'); + controls.expectDisabled(false); + const original = JSON.parse(controls.fetchMock.mock.calls[1][1].body); + expect(controls.sessionStorage.getItem('member-primary-email:user_credential')).toBeNull(); + controls.fetchMock.mockRejectedValueOnce(new Error('Response lost')); + await controls.setPrimaryEmail('alias@example.test'); + expect(JSON.parse(controls.fetchMock.mock.calls[2][1].body).operation_id).not.toBe(original.operation_id); + expect(controls.newOperationId).toHaveBeenCalledTimes(2); + }); + + it('does not send an email mutation if its caller operation cannot be persisted', async () => { + const controls = loadControls(); + await controls.loadReady(); + const storage = Object.getPrototypeOf(controls.sessionStorage); + const setItem = vi.spyOn(storage, 'setItem').mockImplementation(() => { throw new Error('Storage unavailable'); }); + try { + await controls.setPrimaryEmail('alias@example.test'); + expect(controls.fetchMock).toHaveBeenCalledOnce(); + controls.expectDisabled(true); + } finally { setItem.mockRestore(); } + }); + + it('keeps an unknown server outcome disabled without inventing reconciliation state', async () => { + const controls = loadControls(); + await controls.loadReady(); + controls.fetchMock.mockResolvedValueOnce(response({ status_unknown: true, message: 'Reload to check the outcome.' }, 503)); + await controls.setPrimaryEmail('alias@example.test'); + controls.expectDisabled(true); + expect(controls.document.getElementById('linkedEmailStatusMessage')?.textContent).toContain('Reload to check the outcome.'); + }); + + it('scopes persisted caller operations to the exact authenticated credential', async () => { + const first = loadControls(); + await first.loadReady(); + first.fetchMock.mockRejectedValueOnce(new Error('Response lost')); + await first.setPrimaryEmail('alias@example.test'); + const original = JSON.parse(first.fetchMock.mock.calls[1][1].body); + const other = loadControls(first.sessionStorage); + other.fetchMock.mockResolvedValueOnce(response(emailStatus({ credential_id: 'user_other' }))); + await other.loadLinkedEmails(); + other.fetchMock.mockRejectedValueOnce(new Error('Response lost')); + await other.setPrimaryEmail('alias@example.test'); + expect(JSON.parse(other.fetchMock.mock.calls[1][1].body).operation_id).not.toBe(original.operation_id); + }); + + it('disables mutations before and during the initial authoritative status load', async () => { + const controls = loadControls(); + controls.expectDisabled(true); + controls.enterEmail(); + await controls.sendLinkVerification(); + await controls.setPrimaryEmail('alias@example.test'); + expect(controls.fetchMock).not.toHaveBeenCalled(); + expect(controls.confirmMock).not.toHaveBeenCalled(); + + const pending = deferredResponse(); + controls.fetchMock.mockReturnValueOnce(pending.promise); + const load = controls.loadLinkedEmails(); + controls.expectDisabled(true); + pending.resolve(response(emailStatus())); + await load; + controls.expectDisabled(false); + }); + + it('restores reconciliation on page load and renders provider-safe support text without HTML execution', async () => { + const controls = loadControls(); + const message = supportMessage + ' '; + controls.fetchMock.mockResolvedValueOnce(response(emailStatus({ reconciliation_required: true, message }))); + await controls.loadLinkedEmails(); + + controls.expectDisabled(true); + const notice = controls.document.getElementById('linkedEmailStatus')!; + expect(notice.style.display).toBe('block'); + expect(notice.textContent).toContain(message); + expect(notice.querySelector('img')).toBeNull(); + expect(notice.querySelector('a')?.href).toBe('mailto:support@agenticadvertising.org'); + controls.openLinkEmailModal(); + expect(controls.document.getElementById('linkEmailModal')?.classList.contains('show')).toBe(false); + }); + + it.each([409, 503])('keeps all controls disabled after a primary mutation requires reconciliation (%s)', async (status) => { + const controls = loadControls(); + await controls.loadReady(); + controls.fetchMock.mockResolvedValueOnce(response({ + error: 'provider_mutation_reconciliation_required', + message: supportMessage, + operation_id: 'operation-test', + reconciliation_required: true, + }, status)); + await controls.setPrimaryEmail('alias@example.test'); + + controls.expectDisabled(true); + expect(controls.showToast).toHaveBeenLastCalledWith(supportMessage, 'error'); + expect(controls.document.getElementById('linkedEmailStatusMessage')?.textContent).toContain(supportMessage); + controls.enterEmail(); + await controls.setPrimaryEmail('alias@example.test'); + await controls.sendLinkVerification(); + expect(controls.fetchMock).toHaveBeenCalledTimes(2); + }); + + it('retains reconciliation after the link-email finally block and modal open attempts', async () => { + const controls = loadControls(); + await controls.loadReady(); + controls.enterEmail(); + controls.fetchMock.mockResolvedValueOnce(response({ + error: 'provider_mutation_reconciliation_required', + message: supportMessage, + reconciliation_required: true, + }, 503)); + await controls.sendLinkVerification(); + + controls.expectDisabled(true); + expect(controls.showToast).toHaveBeenLastCalledWith(supportMessage, 'error'); + expect(controls.document.getElementById('linkEmailWarningMessage')?.textContent).toContain(supportMessage); + expect(controls.document.getElementById('linkEmailWarning')?.style.display).toBe('block'); + controls.openLinkEmailModal(); + controls.expectDisabled(true); + await controls.sendLinkVerification(); + expect(controls.fetchMock).toHaveBeenCalledTimes(2); + }); + + it('only enables reconciliation-blocked controls after a new authoritative false status', async () => { + const controls = loadControls(); + controls.fetchMock.mockResolvedValueOnce(response(emailStatus({ reconciliation_required: true, message: supportMessage }))); + await controls.loadLinkedEmails(); + controls.fetchMock.mockRejectedValueOnce(new Error('Offline')); + await controls.loadLinkedEmails(); + controls.expectDisabled(true); + expect(controls.document.getElementById('linkedEmailStatusMessage')?.textContent).toContain(supportMessage); + + const pending = deferredResponse(); + controls.fetchMock.mockReturnValueOnce(pending.promise); + const load = controls.loadLinkedEmails(); + controls.expectDisabled(true); + pending.resolve(response(emailStatus())); + await load; + controls.expectDisabled(false); + expect(controls.document.getElementById('linkedEmailStatus')?.style.display).toBe('none'); + }); + + it('ignores an older clear status that arrives after a reconciliation status', async () => { + const controls = loadControls(); + const stale = deferredResponse(); + controls.fetchMock.mockReturnValueOnce(stale.promise); + const staleLoad = controls.loadLinkedEmails(); + controls.fetchMock.mockResolvedValueOnce(response(emailStatus({ reconciliation_required: true, message: supportMessage }))); + await controls.loadLinkedEmails(); + stale.resolve(response(emailStatus())); + await staleLoad; + + controls.expectDisabled(true); + expect(controls.document.getElementById('linkedEmailStatusMessage')?.textContent).toContain(supportMessage); + }); + + it.each(['primary', 'link'] as const)('blocks retries after an ambiguous %s transport failure until status can be loaded', async (mutation) => { + const controls = loadControls(); + await controls.loadReady(); + controls.enterEmail(); + controls.fetchMock.mockRejectedValueOnce(new Error('Connection closed before response')); + if (mutation === 'primary') await controls.setPrimaryEmail('alias@example.test'); + else await controls.sendLinkVerification(); + + controls.expectDisabled(true); + expect(controls.document.getElementById('linkedEmailStatusMessage')?.textContent).toContain('Unable to confirm your email change'); + expect(controls.showToast).not.toHaveBeenCalledWith(expect.anything(), 'success'); + await controls.loadReady(); + }); + + it('prevents concurrent link and primary mutations and waits for a fresh status after success', async () => { + const controls = loadControls(); + await controls.loadReady(); + controls.enterEmail(); + const mutation = deferredResponse(); + const reload = deferredResponse(); + controls.fetchMock.mockReturnValueOnce(mutation.promise).mockReturnValueOnce(reload.promise); + const save = controls.setPrimaryEmail('alias@example.test'); + controls.expectDisabled(true); + await controls.sendLinkVerification(); + await controls.setPrimaryEmail('alias@example.test'); + expect(controls.fetchMock).toHaveBeenCalledTimes(2); + + mutation.resolve(response({ success: true })); + await vi.waitFor(() => expect(controls.fetchMock).toHaveBeenCalledTimes(3)); + controls.expectDisabled(true); + reload.resolve(response(emailStatus({ primary_email: 'alias@example.test', aliases: [{ email: 'primary@example.test' }] }))); + await save; + controls.expectDisabled(false); + }); + + it.each([ + { data: { error: 'status_unavailable', message: 'Contact support to check email status.' }, status: 503 }, + { data: emailStatus({ reconciliation_required: undefined }), status: 200 }, + ])('fails closed when status is unavailable or lacks the reconciliation flag', async ({ data, status }) => { + const controls = loadControls(); + controls.fetchMock.mockResolvedValueOnce(response(data, status)); + await controls.loadLinkedEmails(); + + controls.expectDisabled(true); + expect(controls.document.getElementById('linkedEmailStatus')?.style.display).toBe('block'); + }); + + it('keeps a confirmed provider rejection retryable and prefers the support message', async () => { + const controls = loadControls(); + await controls.loadReady(); + controls.fetchMock.mockResolvedValueOnce(response({ + error: 'provider_rejected', + message: 'The provider rejected this address. Contact support if this continues.', + reconciliation_required: false, + }, 409)); + await controls.setPrimaryEmail('alias@example.test'); + + controls.expectDisabled(false); + expect(controls.showToast).toHaveBeenLastCalledWith('The provider rejected this address. Contact support if this continues.', 'error'); + }); +}); diff --git a/server/tests/unit/network-health-security.test.ts b/server/tests/unit/network-health-security.test.ts index 94f35fa58c4..765d8fa5005 100644 --- a/server/tests/unit/network-health-security.test.ts +++ b/server/tests/unit/network-health-security.test.ts @@ -54,6 +54,7 @@ vi.mock('../../src/db/org-filters.js', () => ({ vi.mock('../../src/db/client.js', async (importOriginal) => ({ ...(await importOriginal()), getPool: () => ({ query: mocks.poolQuery }), + queryWithTimeout: mocks.poolQuery, })); vi.mock('../../src/addie/mcp/admin-tools.js', () => ({ @@ -182,10 +183,27 @@ describe('network-health global authorization boundary', () => { mocks.checkPlatformBanForApiKey.mockResolvedValue({ banned: false }); mocks.checkPlatformBan.mockResolvedValue({ banned: false }); mocks.isWebUserAAOAdmin.mockResolvedValue(false); - mocks.poolQuery.mockImplementation((sql: string) => { - if (sql.includes('FROM users')) { + mocks.poolQuery.mockImplementation((sql: string, params: unknown[]) => { + if (sql.includes('pg_catalog.pg_is_in_recovery()')) { + const userId = params[0] as string; + const isAdmin = userId === 'user_platform_admin'; return Promise.resolve({ - rows: [{ first_name: 'Regular', last_name: 'User' }], + rows: [{ + in_recovery: false, + authenticated_user_id: userId, + canonical_user_id: userId, + identity_id: `identity_${userId}`, + authorization_epoch: '0', + email: isAdmin ? 'platform-admin@example.test' : 'user@example.test', + email_verified: true, email_mutation_pending: false, + first_name: isAdmin ? 'Platform' : 'Regular', + last_name: isAdmin ? 'Admin' : 'User', + grant_id: null, + grant_organization_id: null, + grant_role: null, + grant_effective_from: null, + grant_effective_until: null, + }], rowCount: 1, }); } diff --git a/server/tests/unit/resolve-user-org-authorization.test.ts b/server/tests/unit/resolve-user-org-authorization.test.ts index 8d4aa180609..144ce2dbebb 100644 --- a/server/tests/unit/resolve-user-org-authorization.test.ts +++ b/server/tests/unit/resolve-user-org-authorization.test.ts @@ -1,159 +1,197 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { WorkOS } from "@workos-inc/node"; - -const queryMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../src/db/client.js", () => ({ query: queryMock })); - -import { - evaluateUserOrgRoleAuthorization, - resolveUserOrgAuthorization, -} from "../../src/utils/resolve-user-org-authorization.js"; - -const ORGANIZATION_ID = "org_authorization_test"; - -function workosWithMemberships(data: unknown[]): WorkOS { +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkOS } from '@workos-inc/node'; +import type { AuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; + +const snapshotMock = vi.hoisted(() => vi.fn()); +vi.mock('../../src/db/user-authorization-snapshot-db.js', async (importOriginal) => ({ + ...await importOriginal(), + loadAuthorizationSnapshot: snapshotMock, +})); +import { evaluateUserOrgRoleAuthorization, resolveUserOrgAuthorization } from '../../src/utils/resolve-user-org-authorization.js'; + +const ORGANIZATION_ID = 'org_pinnacle'; +const AUTHENTICATED_ID = 'user_authenticated'; +function snapshot(overrides: Partial = {}): AuthorizationSnapshot { + return { + authenticatedUserId: AUTHENTICATED_ID, canonicalUserId: 'user_canonical', + identityId: 'identity_linked', selectedOrganizationId: ORGANIZATION_ID, authorizationEpoch: '1', + credential: { email: 'sam@example.test', firstName: 'Sam', lastName: 'Adeyemi', emailVerified: true, emailMutationPending: false }, + credentialGrant: null, ...overrides, + }; +} +function grant(role: 'owner' | 'admin' | 'member' = 'member'): NonNullable { return { - userManagement: { - listOrganizationMemberships: vi.fn().mockResolvedValue({ data }), - }, + id: 'grant_pinnacle', organizationId: ORGANIZATION_ID, role, + effectiveFrom: '2026-01-01T00:00:00.000000Z', effectiveUntil: null, + }; +} +function principal(authorizationSnapshot?: AuthorizationSnapshot) { + return { id: 'user_canonical', authWorkosUserId: AUTHENTICATED_ID, authorizationSnapshot }; +} +function workosWithMemberships(data: unknown[] = []): WorkOS { + return { + userManagement: { listOrganizationMemberships: vi.fn().mockResolvedValue({ data }) }, } as unknown as WorkOS; } +function membership(organizationId = ORGANIZATION_ID, role = 'member', userId = AUTHENTICATED_ID) { + return { userId, organizationId, status: 'active', role: { slug: role } }; +} +const FORBIDDEN = { status: 'forbidden', complete: true, unavailableSources: [] }; +const SNAPSHOT_UNAVAILABLE = { status: 'unavailable', complete: false, unavailableSources: ['authorization_snapshot'] }; -describe("resolveUserOrgAuthorization", () => { +describe('resolveUserOrgAuthorization', () => { beforeEach(() => { - queryMock.mockReset(); - queryMock.mockResolvedValue({ rows: [] }); + snapshotMock.mockReset(); + snapshotMock.mockImplementation(async (id: string, org: string | null) => snapshot({ + authenticatedUserId: id, selectedOrganizationId: org, + })); }); - - it("uses the exact authenticated credential rather than the canonical identity user", async () => { - const workos = workosWithMemberships([ - { - organizationId: ORGANIZATION_ID, - status: "active", - role: { slug: "member" }, - }, - ]); - - await resolveUserOrgAuthorization( - workos, - { id: "user_canonical", authWorkosUserId: "user_authenticated" }, - ORGANIZATION_ID - ); - - expect( - workos.userManagement.listOrganizationMemberships - ).toHaveBeenCalledWith({ - userId: "user_authenticated", - organizationId: ORGANIZATION_ID, + it.each([ + ['secondary credential', 'user_canonical', AUTHENTICATED_ID], + ['primary credential', AUTHENTICATED_ID, undefined], + ])('uses exact %s authority independently of canonical attribution', async (_label, id, authWorkosUserId) => { + const workos = workosWithMemberships([membership()]); + const result = await resolveUserOrgAuthorization(workos, { id, authWorkosUserId }, ORGANIZATION_ID); + expect(result).toMatchObject({ status: 'authorized', membership: { source: 'workos' } }); + expect(workos.userManagement.listOrganizationMemberships).toHaveBeenCalledExactlyOnceWith({ + userId: AUTHENTICATED_ID, organizationId: ORGANIZATION_ID, }); - expect(queryMock).toHaveBeenCalledWith(expect.any(String), [ - "user_authenticated", - ORGANIZATION_ID, - ]); + expect(snapshotMock).toHaveBeenCalledTimes(2); + expect(snapshotMock).toHaveBeenNthCalledWith(1, AUTHENTICATED_ID, ORGANIZATION_ID); + expect(snapshotMock).toHaveBeenNthCalledWith(2, AUTHENTICATED_ID, ORGANIZATION_ID); }); - - it("returns a definitive denial only when both authority sources are available", async () => { - const result = await resolveUserOrgAuthorization( - workosWithMemberships([]), - { id: "user_without_access" }, - ORGANIZATION_ID - ); - - expect(result).toEqual({ - status: "forbidden", - complete: true, - unavailableSources: [], - }); + it('returns a definitive denial only when both authority sources are available', async () => { + expect(await resolveUserOrgAuthorization(workosWithMemberships(), principal(), ORGANIZATION_ID)).toEqual(FORBIDDEN); }); - - it("distinguishes an authority-source outage from a denial", async () => { - queryMock.mockRejectedValue(new Error("database unavailable")); - - const result = await resolveUserOrgAuthorization( - workosWithMemberships([]), - { id: "user_unknown" }, - ORGANIZATION_ID - ); - - expect(result).toEqual({ - status: "unavailable", - complete: false, - unavailableSources: ["credential_grant"], - }); - expect(evaluateUserOrgRoleAuthorization(result)).toEqual({ - status: "unavailable", - unavailableSources: ["credential_grant"], - }); + it.each(['', ' '])('never chooses an implicit organization for missing selection %j', async (org) => { + const workos = workosWithMemberships([membership()]); + expect(await resolveUserOrgAuthorization(workos, principal(), org)).toEqual(FORBIDDEN); + expect(workos.userManagement.listOrganizationMemberships).not.toHaveBeenCalled(); + expect(snapshotMock).not.toHaveBeenCalled(); }); - - it("uses an active exact-credential grant and preserves partial role uncertainty", async () => { - queryMock.mockResolvedValue({ - rows: [{ workos_organization_id: ORGANIZATION_ID, role: "member" }], + it.each([ + ['another linked credential', membership(ORGANIZATION_ID, 'owner', 'user_canonical')], + ['another organization', membership('org_streamhaus', 'owner')], + ])('does not inherit membership from %s', async (_label, otherMembership) => { + const workos = workosWithMemberships([otherMembership]); + expect(await resolveUserOrgAuthorization(workos, principal(), ORGANIZATION_ID)).toEqual(FORBIDDEN); + expect(workos.userManagement.listOrganizationMemberships).toHaveBeenCalledWith({ + userId: AUTHENTICATED_ID, organizationId: ORGANIZATION_ID, }); - const workos = { - userManagement: { - listOrganizationMemberships: vi - .fn() - .mockRejectedValue(new Error("WorkOS unavailable")), - }, - } as unknown as WorkOS; - + }); + it('rejects replaying a selected organization snapshot against another organization', async () => { + const workos = workosWithMemberships([membership('org_streamhaus')]); + const result = await resolveUserOrgAuthorization(workos, principal(snapshot()), 'org_streamhaus'); + expect(result).toEqual(FORBIDDEN); + expect(workos.userManagement.listOrganizationMemberships).not.toHaveBeenCalled(); + }); + it('binds an unselected authenticated snapshot to an explicit organization using fresh authority', async () => { const result = await resolveUserOrgAuthorization( - workos, - { id: "user_granted" }, - ORGANIZATION_ID + workosWithMemberships([membership('org_streamhaus')]), + principal(snapshot({ selectedOrganizationId: null })), 'org_streamhaus', ); - + expect(result).toMatchObject({ status: 'authorized', membership: { organizationId: 'org_streamhaus', source: 'workos' } }); + }); + it('distinguishes snapshot outage from denial without falling back to valid provider membership', async () => { + snapshotMock.mockRejectedValue(new Error('database unavailable')); + const workos = workosWithMemberships([membership(ORGANIZATION_ID, 'owner')]); + const result = await resolveUserOrgAuthorization(workos, principal(), ORGANIZATION_ID); + expect(result).toEqual(SNAPSHOT_UNAVAILABLE); + expect(evaluateUserOrgRoleAuthorization(result)).toEqual({ status: 'unavailable', unavailableSources: ['authorization_snapshot'] }); + expect(workos.userManagement.listOrganizationMemberships).not.toHaveBeenCalled(); + }); + it('fails unavailable if the post-provider primary revalidation fails', async () => { + snapshotMock.mockResolvedValueOnce(snapshot({ credentialGrant: grant('owner') })) + .mockRejectedValueOnce(new Error('database unavailable')); + const result = await resolveUserOrgAuthorization(workosWithMemberships([membership()]), principal(), ORGANIZATION_ID); + expect(result).toEqual(SNAPSHOT_UNAVAILABLE); + }); + it('uses an active exact-credential grant and preserves partial role uncertainty', async () => { + snapshotMock.mockResolvedValue(snapshot({ credentialGrant: grant() })); + const workos = workosWithMemberships(); + vi.mocked(workos.userManagement.listOrganizationMemberships).mockRejectedValue(new Error('WorkOS unavailable')); + const result = await resolveUserOrgAuthorization(workos, principal(), ORGANIZATION_ID); expect(result).toMatchObject({ - status: "authorized", - complete: false, - unavailableSources: ["workos"], - membership: { role: "member", source: "credential_grant" }, - }); - expect(evaluateUserOrgRoleAuthorization(result, "member")).toMatchObject({ - status: "authorized", - membership: { role: "member" }, + status: 'authorized', complete: false, unavailableSources: ['workos'], + membership: { role: 'member', source: 'credential_grant' }, }); - expect(evaluateUserOrgRoleAuthorization(result, "admin")).toEqual({ - status: "unavailable", - unavailableSources: ["workos"], + expect(evaluateUserOrgRoleAuthorization(result, 'member')).toMatchObject({ status: 'authorized' }); + expect(evaluateUserOrgRoleAuthorization(result, 'admin')).toEqual({ status: 'unavailable', unavailableSources: ['workos'] }); + }); + it('returns unavailable without an independent grant when WorkOS cannot answer', async () => { + expect(await resolveUserOrgAuthorization(null, principal(), ORGANIZATION_ID)).toEqual({ + status: 'unavailable', complete: false, unavailableSources: ['workos'], }); }); - - it("definitively denies a complete exact membership below the required role", () => { + it('definitively denies a complete exact membership below the required role', () => { expect(evaluateUserOrgRoleAuthorization({ - status: "authorized", - membership: { - organizationId: ORGANIZATION_ID, - role: "member", - source: "workos", - }, - complete: true, - unavailableSources: [], - }, "admin")).toEqual({ status: "forbidden" }); + status: 'authorized', complete: true, unavailableSources: [], + membership: { organizationId: ORGANIZATION_ID, role: 'member', source: 'workos' }, + }, 'admin')).toEqual({ status: 'forbidden' }); }); - - it("chooses the highest role while retaining its authority source", async () => { - queryMock.mockResolvedValue({ - rows: [{ workos_organization_id: ORGANIZATION_ID, role: "owner" }], - }); - const result = await resolveUserOrgAuthorization( - workosWithMemberships([ - { - organizationId: ORGANIZATION_ID, - status: "active", - role: { slug: "admin" }, - }, - ]), - { id: "user_with_two_sources" }, - ORGANIZATION_ID - ); - - expect(result).toMatchObject({ - status: "authorized", - complete: true, - membership: { role: "owner", source: "credential_grant" }, + it('chooses the highest role while retaining its authority source', async () => { + snapshotMock.mockResolvedValue(snapshot({ credentialGrant: grant('owner') })); + const result = await resolveUserOrgAuthorization(workosWithMemberships([membership(ORGANIZATION_ID, 'admin')]), principal(), ORGANIZATION_ID); + expect(result).toMatchObject({ status: 'authorized', complete: true, membership: { role: 'owner', source: 'credential_grant' } }); + }); + it.each([ + ['epoch advanced', { authorizationEpoch: '2' }], + ['epoch moved backwards', { authorizationEpoch: '0' }], + ['canonical identity changed', { canonicalUserId: 'user_new_primary' }], + ['binding changed without a matching epoch', { identityId: 'identity_new' }], + ['credential changed', { authenticatedUserId: 'user_other' }], + ] as const)('rejects replayed request state when %s', async (_case, change) => { + const workos = workosWithMemberships([membership()]); + snapshotMock.mockResolvedValue(snapshot(change)); + expect(await resolveUserOrgAuthorization(workos, principal(snapshot()), ORGANIZATION_ID)).toEqual(FORBIDDEN); + expect(workos.userManagement.listOrganizationMemberships).not.toHaveBeenCalled(); + }); + it.each([ + ['revoked', null], ['replaced', { ...grant('owner'), id: 'grant_replacement' }], + ])('rejects a %s grant in prior request state even when the epoch is unchanged', async (_label, currentGrant) => { + snapshotMock.mockResolvedValue(snapshot({ credentialGrant: currentGrant })); + const workos = workosWithMemberships([membership(ORGANIZATION_ID, 'owner')]); + const result = await resolveUserOrgAuthorization(workos, principal(snapshot({ credentialGrant: grant('owner') })), ORGANIZATION_ID); + expect(result).toEqual(FORBIDDEN); + expect(workos.userManagement.listOrganizationMemberships).not.toHaveBeenCalled(); + }); + it.each([ + ['identity binding', { canonicalUserId: 'user_new_primary', identityId: 'identity_new', authorizationEpoch: '2' }], + ['epoch alone', { authorizationEpoch: '2' }], + ['grant revoked', { credentialGrant: null }], + ['grant role', { credentialGrant: grant('member') }], + ['grant replaced', { credentialGrant: { ...grant('owner'), id: 'grant_replacement' } }], + ['grant end date', { credentialGrant: { ...grant('owner'), effectiveUntil: '2026-09-13T00:00:00.000000Z' } }], + ['selected organization', { selectedOrganizationId: 'org_streamhaus' }], + ] as const)('rejects a %s change while a provider decision is in flight', async (_case, change) => { + const before = snapshot({ credentialGrant: grant('owner') }); + let current = before; + snapshotMock.mockImplementation(async () => current); + let finishProvider!: (value: { data: ReturnType[] }) => void; + let providerStarted!: () => void; + const started = new Promise((resolve) => { providerStarted = resolve; }); + const workos = workosWithMemberships(); + vi.mocked(workos.userManagement.listOrganizationMemberships).mockImplementation(() => { + providerStarted(); + return new Promise((resolve) => { finishProvider = resolve as typeof finishProvider; }); }); + const pending = resolveUserOrgAuthorization(workos, principal(before), ORGANIZATION_ID); + await started; + current = snapshot({ ...before, ...change }); + finishProvider({ data: [membership(ORGANIZATION_ID, 'owner')] }); + expect(await pending).toEqual(FORBIDDEN); + expect(snapshotMock).toHaveBeenCalledTimes(2); + }); + it('rejects a credential deleted during provider authorization', async () => { + snapshotMock.mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(null); + expect(await resolveUserOrgAuthorization(workosWithMemberships([membership()]), principal(), ORGANIZATION_ID)).toEqual(FORBIDDEN); + }); + it('checks persisted epoch each turn and rejects replayed prior context', async () => { + const before = snapshot(); + const workos = workosWithMemberships([membership()]); + expect(await resolveUserOrgAuthorization(workos, principal(before), ORGANIZATION_ID)).toMatchObject({ status: 'authorized' }); + snapshotMock.mockResolvedValue(snapshot({ authorizationEpoch: '2' })); + expect(await resolveUserOrgAuthorization(workos, principal(before), ORGANIZATION_ID)).toEqual(FORBIDDEN); + expect(workos.userManagement.listOrganizationMemberships).toHaveBeenCalledTimes(1); }); }); diff --git a/server/tests/unit/set-primary-email.test.ts b/server/tests/unit/set-primary-email.test.ts index ec783dfc3b5..ac61fc83346 100644 --- a/server/tests/unit/set-primary-email.test.ts +++ b/server/tests/unit/set-primary-email.test.ts @@ -1,79 +1,185 @@ -import { describe, it, expect } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; import { isEmailUnavailable } from '../../src/routes/account-linking-errors.js'; -/** - * Static analysis test for the set-primary-email endpoint. - * Verifies the route handler exists and follows the correct swap pattern. - */ - -const ACCOUNT_LINKING_FILE = path.resolve( - __dirname, - '../../src/routes/account-linking.ts' -); - -const DASHBOARD_SETTINGS_FILE = path.resolve( - __dirname, - '../../public/dashboard-settings.html' -); - -describe('Set primary email endpoint', () => { - const source = fs.readFileSync(ACCOUNT_LINKING_FILE, 'utf-8'); - - it('registers a PUT /primary route on the router', () => { - expect(source).toMatch(/router\.put\(\s*['"]\/primary['"]/); - }); - - it('requires authentication and rate limiting', () => { - expect(source).toMatch(/router\.put\(\s*['"]\/primary['"],\s*requireAuth,\s*verifyExecuteLimiter/); - }); - - it('validates email is a verified alias before swapping', () => { - expect(source).toMatch(/user_email_aliases/); - expect(source).toMatch(/workos_user_id = \$1 AND LOWER\(email\) = \$2 AND verified_at IS NOT NULL/); - }); - - it('updates WorkOS as source of truth', () => { - expect(source).toMatch(/getWorkos\(\)\.userManagement\.updateUser/); - }); - - it('swaps emails in a transaction', () => { - // Must use BEGIN/COMMIT for atomicity - expect(source).toMatch(/BEGIN/); - expect(source).toMatch(/COMMIT/); - // Must update users table - expect(source).toMatch(/UPDATE users SET email/); - // Must delete alias for new primary - expect(source).toMatch(/DELETE FROM user_email_aliases/); - // Must insert old primary as alias - expect(source).toMatch(/INSERT INTO user_email_aliases[\s\S]*?VALUES/); - }); - - it('locks alias row with FOR UPDATE to prevent races', () => { - expect(source).toMatch(/FOR UPDATE/); - }); - - it('updates WorkOS before the DB swap so a WorkOS rejection leaves DB state untouched', () => { - const beginIdx = source.indexOf("'BEGIN'"); - const workosIdx = source.indexOf('getWorkos().userManagement.updateUser'); - expect(workosIdx).toBeGreaterThan(-1); - expect(beginIdx).toBeGreaterThan(-1); - expect(workosIdx).toBeLessThan(beginIdx); - }); - - it('classifies WorkOS rejections via isEmailUnavailable and returns a friendly 409', () => { - expect(source).toMatch(/isEmailUnavailable\(workosError\)/); - expect(source).toMatch(/already associated with another account/); - }); - - it('rolls back on error', () => { - expect(source).toMatch(/ROLLBACK/); - }); +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + status: vi.fn(), + mutate: vi.fn(), + clientQuery: vi.fn(), + release: vi.fn(), + principal: null as any, + apiKey: false, +})); +const client = { query: mocks.clientQuery, release: mocks.release }; +vi.mock('../../src/db/client.js', () => ({ query: mocks.query, getPool: () => ({ connect: async () => client }) })); +vi.mock('../../src/middleware/auth.js', () => ({ + requireAuth: (req: any, _res: any, next: any) => { + req.user = mocks.principal ?? { id: 'user_canonical', authWorkosUserId: 'user_credential', email: 'canonical@example.test', authorizationSnapshot: { authenticatedUserId: 'user_credential' } }; + if (mocks.apiKey) req.apiKey = { id: 'test-api-key', scopes: ['admin:*'] }; + next(); + }, +})); +vi.mock('express-rate-limit', () => ({ default: () => (_req: any, _res: any, next: any) => next() })); +vi.mock('../../src/middleware/pg-rate-limit-store.js', () => ({ CachedPostgresStore: class {} })); +vi.mock('../../src/notifications/email.js', () => ({ sendEmailLinkVerification: vi.fn() })); +vi.mock('../../src/services/email-mutation.js', () => ({ + getEmailMutationStatus: mocks.status, + setPrimaryEmail: mocks.mutate, + EmailMutationError: class extends Error { + constructor(public status: number, public body: Record) { super('Email mutation failed'); } + }, +})); +import { createAccountLinkingRouter, handleEmailLinkVerification } from '../../src/routes/account-linking.js'; +import { EmailMutationError } from '../../src/services/email-mutation.js'; + +const app = express(); +app.use(express.json()); +app.use('/api/me/linked-emails', createAccountLinkingRouter()); +handleEmailLinkVerification(app); +const operationId = '7bc02bce-fb04-47ac-8104-c3bb4b93a1e7'; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.principal = null; + mocks.apiKey = false; + mocks.status.mockResolvedValue({ reconciliation_required: false }); + mocks.query.mockResolvedValue({ rows: [] }); + mocks.clientQuery.mockResolvedValue({ rows: [] }); +}); - it('refreshes denormalized email on organization_memberships and person_relationships', () => { - expect(source).toMatch(/UPDATE organization_memberships SET email/); - expect(source).toMatch(/UPDATE person_relationships SET email/); +describe('Primary email credential boundary', () => { + it.each(['missing snapshot', 'API key'])('rejects %s before mutation or journal lookup', async (kind) => { + if (kind === 'API key') mocks.apiKey = true; + else mocks.principal = { id: 'user_credential', email: 'credential@example.test' }; + const response = await request(app).put('/api/me/linked-emails/primary').send({ email: 'new@example.test', operation_id: operationId }); + expect(response.status).toBe(403); + expect(mocks.mutate).not.toHaveBeenCalled(); + expect(mocks.status).not.toHaveBeenCalled(); + expect(mocks.query).not.toHaveBeenCalled(); + }); + + it('rejects a snapshot belonging to a different authenticated credential', async () => { + mocks.principal = { id: 'other_canonical', authWorkosUserId: 'provider_claim', authorizationSnapshot: { authenticatedUserId: 'user_credential' } }; + mocks.mutate.mockResolvedValue({ status: 'primary_updated' }); + const response = await request(app).put('/api/me/linked-emails/primary').send({ email: 'new@example.test', operation_id: operationId }); + expect(response.status).toBe(403); + expect(mocks.mutate).not.toHaveBeenCalled(); + }); + + it('mutates the credential that authenticated, regardless of canonical identity direction', async () => { + mocks.mutate.mockResolvedValue({ status: 'primary_updated', primary_email: 'new@example.test', operation_id: operationId }); + const response = await request(app).put('/api/me/linked-emails/primary').send({ email: 'new@example.test', operation_id: operationId }); + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'primary_updated', primary_email: 'new@example.test', operation_id: operationId }); + expect(mocks.mutate).toHaveBeenCalledWith({ userId: 'user_credential', actorUserId: 'user_credential', email: 'new@example.test', operationId }); + }); + + it('passes through the safe reconciliation response without claiming success', async () => { + const body = { reconciliation_required: true, operation_id: 'operation-1', message: 'Contact support to reconcile this change.' }; + mocks.mutate.mockRejectedValue(new EmailMutationError(503, body)); + const response = await request(app).put('/api/me/linked-emails/primary').send({ email: 'new@example.test' }); + expect(response.status).toBe(503); + expect(response.body).toEqual(body); + expect(response.body.status).not.toBe('primary_updated'); + }); + + it('fails closed without exposing unexpected provider exceptions', async () => { + mocks.mutate.mockRejectedValue(new Error('Authorization: Bearer secret-token')); + const response = await request(app).put('/api/me/linked-emails/primary').send({ email: 'new@example.test' }); + expect(response.status).toBe(503); + expect(response.body.reconciliation_required).toBeUndefined(); + expect(response.body.status_unknown).toBe(true); + expect(response.body.message).toMatch(/contact support/i); + expect(JSON.stringify(response.body)).not.toContain('secret-token'); + }); + + it('lists only the authenticated credential emails and durable reconciliation status', async () => { + mocks.status.mockResolvedValue({ reconciliation_required: true, operation_id: 'operation-1', message: 'Contact support.' }); + mocks.query.mockImplementation(async (sql: string, params: unknown[]) => { + expect(params).toEqual(['user_credential']); + return { rows: sql.includes('SELECT email FROM users') ? [{ email: 'credential@example.test' }] : [] }; + }); + const response = await request(app).get('/api/me/linked-emails'); + expect(response.status).toBe(200); + expect(mocks.status).toHaveBeenCalledWith('user_credential'); + expect(response.body).toEqual({ + credential_id: 'user_credential', + primary_email: 'credential@example.test', aliases: [], pending: [], + reconciliation_required: true, operation_id: 'operation-1', message: 'Contact support.', + }); + }); + + it('returns credential contention as retryable without inventing a reconciliation operation', async () => { + const body = { error: 'credential_busy', message: 'Please retry.', retryable: true }; + mocks.mutate.mockRejectedValue(new EmailMutationError(409, body)); + const response = await request(app).put('/api/me/linked-emails/primary').send({ email: 'new@example.test', operation_id: operationId }); + expect(response.status).toBe(409); + expect(response.body).toEqual(body); + expect(response.body.reconciliation_required).toBeUndefined(); + expect(response.body.operation_id).toBeUndefined(); + }); + + it('recovers a durable operation status after an unexpected failure without exposing diagnostics', async () => { + mocks.mutate.mockRejectedValue(new Error('provider-secret')); + const status = { reconciliation_required: true, operation_id: operationId, message: 'Contact support.' }; + mocks.status.mockResolvedValue(status); + const response = await request(app).put('/api/me/linked-emails/primary').send({ email: 'new@example.test', operation_id: operationId }); + expect(response.status).toBe(503); + expect(response.body).toEqual(status); + expect(mocks.status).toHaveBeenCalledWith('user_credential'); + }); + + it('checks verification reconciliation with the already-held transaction client', async () => { + mocks.clientQuery.mockImplementation(async (sql: string) => ({ + rows: sql.includes('FROM email_link_tokens') ? [{ + id: 'token-id', primary_workos_user_id: 'user_credential', target_email: 'alias@example.test', + status: 'pending', expires_at: new Date(Date.now() + 60_000), + }] : [], + })); + mocks.status.mockResolvedValue({ reconciliation_required: true, message: 'Contact support.' }); + const response = await request(app).post('/verify-email-link').send({ token: 'verification-token' }); + expect(response.status).toBe(200); + expect(mocks.status).toHaveBeenCalledExactlyOnceWith('user_credential', client); + expect(mocks.clientQuery).toHaveBeenCalledWith('ROLLBACK'); + expect(mocks.query).not.toHaveBeenCalled(); + expect(mocks.release).toHaveBeenCalledOnce(); + expect(response.text).toContain('Contact support.'); + }); + + it('blocks new email verification while reconciliation is outstanding', async () => { + mocks.status.mockResolvedValue({ reconciliation_required: true, message: 'Contact support.' }); + const response = await request(app).post('/api/me/linked-emails').send({ email: 'new@example.test' }); + expect(response.status).toBe(409); + expect(response.body.reconciliation_required).toBe(true); + expect(mocks.query).not.toHaveBeenCalled(); + }); + + it.each(['reconciliation', 'busy', 'unknown'] as const)('surfaces a concurrent token writer failure as %s instead of an unclassified error', async (kind) => { + mocks.status.mockResolvedValueOnce({ reconciliation_required: false }); + if (kind === 'reconciliation') { + mocks.status.mockResolvedValueOnce({ reconciliation_required: true, operation_id: operationId, message: 'Contact support to reconcile.' }); + } else if (kind === 'unknown') { + mocks.status.mockRejectedValueOnce(new Error('database diagnostic')); + } else mocks.status.mockResolvedValueOnce({ reconciliation_required: false }); + mocks.query.mockImplementation(async (sql: string) => { + if (sql.includes('INSERT INTO email_link_tokens')) throw Object.assign(new Error('database diagnostic'), { code: kind === 'busy' ? '55P03' : '23514' }); + if (sql.includes('COUNT(*)')) return { rows: [{ count: '0' }] }; + if (sql.includes('SELECT email FROM users')) return { rows: [{ email: 'credential@example.test' }] }; + return { rows: [] }; + }); + const response = await request(app).post('/api/me/linked-emails').send({ email: 'new@example.test' }); + expect(response.status).toBe(kind === 'busy' ? 409 : 503); + expect(mocks.status).toHaveBeenCalledTimes(2); + expect(JSON.stringify(response.body)).not.toContain('diagnostic'); + if (kind === 'reconciliation') expect(response.body).toEqual({ reconciliation_required: true, operation_id: operationId, message: 'Contact support to reconcile.' }); + else { + expect(response.body.reconciliation_required).toBeUndefined(); + expect(response.body.operation_id).toBeUndefined(); + if (kind === 'busy') expect(response.body).toMatchObject({ error: 'credential_busy', retryable: true }); + else expect(response.body.status_unknown).toBe(true); + } }); }); @@ -115,49 +221,3 @@ describe('isEmailUnavailable', () => { expect(isEmailUnavailable(undefined)).toBe(false); }); }); - -describe('Set primary email UI', () => { - const html = fs.readFileSync(DASHBOARD_SETTINGS_FILE, 'utf-8'); - - it('renders a "Make primary" button for each alias', () => { - expect(html).toMatch(/make-primary-btn/); - expect(html).toMatch(/Make primary/); - }); - - it('calls PUT /api/me/linked-emails/primary', () => { - expect(html).toMatch(/\/api\/me\/linked-emails\/primary/); - expect(html).toMatch(/method:\s*['"]PUT['"]/); - }); - - it('confirms before changing primary', () => { - expect(html).toMatch(/confirm\(/); - }); - - it('disables buttons during request to prevent double-click', () => { - expect(html).toMatch(/b\.disabled = true/); - expect(html).toMatch(/b\.disabled = false/); - }); - - it('reloads linked emails after success', () => { - expect(html).toMatch(/loadLinkedEmails\(\)/); - }); -}); - -describe('Link email recovery UI', () => { - const html = fs.readFileSync(DASHBOARD_SETTINGS_FILE, 'utf-8'); - - it('prefers the server recovery message over the generic error', () => { - expect(html).toMatch(/data\.message \|\| data\.error \|\| 'Failed to send verification'/); - }); - - it('shows the recovery message safely with an actionable support link', () => { - expect(html).toContain('href="mailto:support@agenticadvertising.org"'); - expect(html).toMatch(/linkEmailWarningMessage'\)\.textContent = message/); - expect(html).toMatch(/linkEmailWarning'\)\.style\.display = 'block'/); - }); - - it('does not promise to merge existing accounts through self-service', () => { - expect(html).toContain("Existing accounts can't be combined through this form"); - expect(html).not.toContain('your accounts will be merged'); - }); -}); diff --git a/server/tests/unit/user-authorization-snapshot-timeout.test.ts b/server/tests/unit/user-authorization-snapshot-timeout.test.ts new file mode 100644 index 00000000000..f25dc83181b --- /dev/null +++ b/server/tests/unit/user-authorization-snapshot-timeout.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { queryWithTimeout } from '../../src/db/client.js'; +import { + AuthorizationSnapshotUnavailableError, + loadAuthorizationSnapshot, + sameAuthorizationIdentity, + sameAuthorizationSnapshot, + type AuthorizationSnapshot, +} from '../../src/db/user-authorization-snapshot-db.js'; + +vi.mock('../../src/db/client.js', async importOriginal => ({ + ...await importOriginal(), + queryWithTimeout: vi.fn(), +})); + +const boundedQuery = vi.mocked(queryWithTimeout); +const USER_ID = 'user_snapshot_timeout'; +const ORGANIZATION_ID = 'org_snapshot_timeout'; + +function queryResult(overrides: Record = {}) { + return { + rows: [{ + in_recovery: false, + authenticated_user_id: USER_ID, + canonical_user_id: USER_ID, + identity_id: 'fd3043f7-cb4f-43c7-9b81-22ac97576150', + authorization_epoch: '7', + email: 'sam@pinnacle.example', + email_verified: true, + email_mutation_pending: false, + first_name: 'Sam', + last_name: 'Adeyemi', + grant_id: null, + grant_organization_id: null, + grant_role: null, + grant_effective_from: null, + grant_effective_until: null, + ...overrides, + }], + rowCount: 1, + command: 'SELECT', + oid: 0, + fields: [], + }; +} + +describe('authorization snapshot query deadline and connection retry', () => { + beforeEach(() => { + boundedQuery.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('retries one transient connection failure with only the remaining absolute budget', async () => { + boundedQuery.mockImplementationOnce(async () => { + vi.setSystemTime(Date.now() + 700); + throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + }).mockResolvedValueOnce(queryResult()); + + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)).resolves.toMatchObject({ + authenticatedUserId: USER_ID, + selectedOrganizationId: ORGANIZATION_ID, + authorizationEpoch: '7', + }); + expect(boundedQuery).toHaveBeenCalledTimes(2); + const [first, second] = boundedQuery.mock.calls; + expect(first[2]).toBe(2_000); + expect(first[3]).toEqual({ retryTransientCheckout: false }); + expect(second).toEqual([first[0], first[1], 1_300, { retryTransientCheckout: false }]); + expect(first[1]).toEqual([USER_ID, ORGANIZATION_ID]); + }); + + it('stops after two connection failures and permits a clean request after recovery', async () => { + boundedQuery.mockRejectedValue(Object.assign(new Error('private connection details'), { code: 'EPIPE' })); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)) + .rejects.toThrow(new AuthorizationSnapshotUnavailableError()); + expect(boundedQuery).toHaveBeenCalledTimes(2); + + boundedQuery.mockResolvedValueOnce(queryResult()); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)).resolves.toMatchObject({ + authenticatedUserId: USER_ID, authorizationEpoch: '7', + }); + expect(boundedQuery).toHaveBeenCalledTimes(3); + }); + + it('does not start a retry after the first attempt exhausts the deadline', async () => { + boundedQuery.mockImplementationOnce(async () => { + vi.setSystemTime(Date.now() + 2_000); + throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + }); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)) + .rejects.toBeInstanceOf(AuthorizationSnapshotUnavailableError); + expect(boundedQuery).toHaveBeenCalledTimes(1); + }); + + it('rejects a result delivered after the absolute deadline without retrying', async () => { + boundedQuery.mockImplementationOnce(async () => { + vi.setSystemTime(Date.now() + 2_001); + return queryResult(); + }); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)) + .rejects.toBeInstanceOf(AuthorizationSnapshotUnavailableError); + expect(boundedQuery).toHaveBeenCalledTimes(1); + }); + + it.each([ + { code: '57014', message: 'canceling statement due to statement timeout' }, + { code: '55P03', message: 'canceling statement due to lock timeout' }, + { code: '42P01', message: 'relation does not exist' }, + ])('does not retry a non-connection database error ($code)', async ({ code, message }) => { + boundedQuery.mockRejectedValue(Object.assign(new Error(message), { code })); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)) + .rejects.toThrow(new AuthorizationSnapshotUnavailableError()); + expect(boundedQuery).toHaveBeenCalledTimes(1); + }); + + it.each([ + { state: 'replica', row: { in_recovery: true } }, + { state: 'missing primary confirmation', row: { in_recovery: undefined } }, + { state: 'malformed primary confirmation', row: { in_recovery: 0 } }, + { state: 'missing primary identity', row: { canonical_user_id: null } }, + ])('does not retry an unavailable $state snapshot', async ({ row }) => { + boundedQuery.mockResolvedValue(queryResult(row)); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)) + .rejects.toBeInstanceOf(AuthorizationSnapshotUnavailableError); + expect(boundedQuery).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, null, 'false', 'true', 0, 1, {}, []])( + 'fails closed for a missing or malformed email mutation flag (%j)', async value => { + boundedQuery.mockResolvedValue(queryResult({ email_mutation_pending: value })); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)) + .rejects.toBeInstanceOf(AuthorizationSnapshotUnavailableError); + expect(boundedQuery).toHaveBeenCalledTimes(1); + }, + ); + + it.each([false, true])('preserves an explicit email mutation flag %s', async pending => { + boundedQuery.mockResolvedValue(queryResult({ email_mutation_pending: pending })); + await expect(loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID)).resolves.toMatchObject({ + credential: { emailMutationPending: pending }, + }); + }); + + it('rejects old or malformed snapshot replay even when both flags are missing', async () => { + boundedQuery.mockResolvedValue(queryResult()); + const current = (await loadAuthorizationSnapshot(USER_ID, ORGANIZATION_ID))!; + for (const value of [undefined, null, 'false', 0]) { + const malformed = { + ...current, credential: { ...current.credential, emailMutationPending: value }, + } as unknown as AuthorizationSnapshot; + expect(sameAuthorizationIdentity(malformed, current)).toBe(false); + expect(sameAuthorizationIdentity(current, malformed)).toBe(false); + expect(sameAuthorizationSnapshot(malformed, malformed)).toBe(false); + } + }); +}); diff --git a/server/tests/unit/working-group-admin-global-auth.test.ts b/server/tests/unit/working-group-admin-global-auth.test.ts index 7c1623375bc..28dcb0f5ec5 100644 --- a/server/tests/unit/working-group-admin-global-auth.test.ts +++ b/server/tests/unit/working-group-admin-global-auth.test.ts @@ -56,6 +56,7 @@ vi.mock('../../src/db/org-filters.js', () => ({ vi.mock('../../src/db/client.js', async (importOriginal) => ({ ...(await importOriginal()), getPool: () => ({ query: mocks.poolQuery }), + queryWithTimeout: mocks.poolQuery, })); vi.mock('../../src/addie/mcp/admin-tools.js', async () => { @@ -136,9 +137,17 @@ describe('working-group real global-admin boundary', () => { ); mocks.isMember.mockResolvedValue(true); mocks.poolQuery.mockImplementation((sql: string) => { - if (sql.includes('FROM users')) { + if (sql.includes('pg_catalog.pg_is_in_recovery()')) { return Promise.resolve({ - rows: [{ first_name: 'SSO', last_name: 'Admin' }], + rows: [{ + in_recovery: false, + authenticated_user_id: 'user_sso_admin', + canonical_user_id: 'user_sso_admin', + identity_id: 'identity_sso_admin', + authorization_epoch: '0', + email: 'sso-admin@example.test', email_verified: true, email_mutation_pending: false, + first_name: 'SSO', last_name: 'Admin', grant_id: null, + }], rowCount: 1, }); } diff --git a/server/tests/unit/working-group-leader-admin-bypass.test.ts b/server/tests/unit/working-group-leader-admin-bypass.test.ts index 23fe1849868..4a9a0881b27 100644 --- a/server/tests/unit/working-group-leader-admin-bypass.test.ts +++ b/server/tests/unit/working-group-leader-admin-bypass.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Request, Response, NextFunction } from 'express'; +import type { AuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; /** * Focused unit tests for the site-admin bypass in the working-group leader @@ -7,7 +8,8 @@ import type { Request, Response, NextFunction } from 'express'; * * The bypass must recognize BOTH aao-admin working group membership (the * deployed primary authority, via isWebUserAAOAdmin) and the ADMIN_EMAILS - * break-glass list — matching requireAdmin. A membership-lookup failure + * break-glass list using a verified, clear local credential snapshot — matching + * requireAdmin. A membership-lookup failure * degrades to non-admin (isWebUserAAOAdmin fails closed) without disabling a * valid break-glass grant. */ @@ -58,9 +60,30 @@ function createWorkingGroupDb(overrides?: { }; } -function createReqRes(email = 'user@example.test') { +function createSnapshot( + email: string, + credential: Partial = {}, +): AuthorizationSnapshot { + return { + authenticatedUserId: 'user_web', + canonicalUserId: 'user_web', + identityId: 'identity_web', + selectedOrganizationId: null, + authorizationEpoch: '1', + credential: { + email, emailVerified: true, emailMutationPending: false, + firstName: null, lastName: null, ...credential, + }, + credentialGrant: null, + }; +} + +function createReqRes( + email = 'user@example.test', + snapshot: AuthorizationSnapshot | null = createSnapshot(email), +) { const req = { - user: { id: 'user_web', email }, + user: { id: 'user_web', email, authorizationSnapshot: snapshot ?? undefined }, params: { slug: 'signals' }, } as unknown as Request; @@ -116,11 +139,11 @@ describe.each([ expect(db.getWorkingGroupBySlug).not.toHaveBeenCalled(); }); - it('allows an ADMIN_EMAILS break-glass user', async () => { + it('allows verified local ADMIN_EMAILS authority despite a stale ordinary raw email', async () => { process.env.ADMIN_EMAILS = 'ops@example.test, admin@example.test'; mocks.isWebUserAAOAdmin.mockResolvedValue(false); const db = createWorkingGroupDb(); - const { req, res, next } = createReqRes('ADMIN@example.test'); + const { req, res, next } = createReqRes('stale@example.test', createSnapshot('ADMIN@example.test')); await factory(db)(req, res, next); @@ -128,6 +151,23 @@ describe.each([ expect(db.getWorkingGroupBySlug).not.toHaveBeenCalled(); }); + it.each([ + { state: 'pending reconciliation', snapshot: createSnapshot('admin@example.test', { emailMutationPending: true }) }, + { state: 'unavailable snapshot', snapshot: null }, + { state: 'unverified local email', snapshot: createSnapshot('admin@example.test', { emailVerified: false }) }, + { state: 'stale raw admin email', snapshot: createSnapshot('member@example.test') }, + ])('denies email-based bypass with $state', async ({ snapshot }) => { + process.env.ADMIN_EMAILS = 'admin@example.test'; + const db = createWorkingGroupDb(); + const { req, res, next } = createReqRes('admin@example.test', snapshot); + + await factory(db)(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(403); + expect(db.getWorkingGroupBySlug).toHaveBeenCalledWith('signals'); + }); + it('denies an ordinary non-leader/non-member', async () => { const db = createWorkingGroupDb({ isLeader: false, isMember: false }); const { req, res, next } = createReqRes(); diff --git a/server/tests/unit/workos-client-timeout.test.ts b/server/tests/unit/workos-client-timeout.test.ts index d11502cfecc..a628a6c430d 100644 --- a/server/tests/unit/workos-client-timeout.test.ts +++ b/server/tests/unit/workos-client-timeout.test.ts @@ -48,6 +48,16 @@ describe('WorkOS client request budgets', () => { }); }); + it('bounds email mutations without retrying ambiguous provider writes', async () => { + const { getEmailMutationWorkos } = await import('../../src/auth/workos-client.js'); + getEmailMutationWorkos(); + getEmailMutationWorkos(); + expect(mocks.constructWorkOS).toHaveBeenCalledOnce(); + expect(mocks.constructWorkOS).toHaveBeenCalledWith('sk_test_timeout', { + clientId: 'client_test_timeout', timeout: 10_000, maxRetries: 0, + }); + }); + it('makes one SDK attempt when retries are disabled', async () => { const { WorkOS } = await vi.importActual( '@workos-inc/node',