From b726fb4c6e199143e70a3dd46e605e3d06282a83 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 13 Sep 2026 14:10:02 +0000 Subject: [PATCH 1/7] fix(auth): hydrate primary authorization snapshots per request --- server/src/auth/organization-principal.ts | 2 +- .../src/db/user-authorization-snapshot-db.ts | 166 ++++++++ server/src/middleware/auth.ts | 374 ++++++----------- server/src/types.ts | 2 + .../utils/resolve-user-org-authorization.ts | 78 ++-- .../user-authorization-snapshot.test.ts | 354 +++++++++++++++++ .../unit/addie-admin-global-auth.test.ts | 12 +- .../unit/authorization-epoch-session.test.ts | 375 ++++++++++++------ .../resolve-user-org-authorization.test.ts | 312 ++++++++------- .../working-group-admin-global-auth.test.ts | 12 +- 10 files changed, 1152 insertions(+), 535 deletions(-) create mode 100644 server/src/db/user-authorization-snapshot-db.ts create mode 100644 server/tests/integration/user-authorization-snapshot.test.ts 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/db/user-authorization-snapshot-db.ts b/server/src/db/user-authorization-snapshot-db.ts new file mode 100644 index 00000000000..74d32facc08 --- /dev/null +++ b/server/src/db/user-authorization-snapshot-db.ts @@ -0,0 +1,166 @@ +import { getPool } from './client.js'; + +/** 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; + 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; + 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; +} + +/** + * 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 pool is the primary + * database; a connection to 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 getPool().query( + `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, + 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) { + 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, + 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; +} + +/** 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/middleware/auth.ts b/server/src/middleware/auth.ts index b1e3c756f70..a272ed5f9ac 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(); @@ -313,19 +312,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 +357,37 @@ 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) { + 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 +677,90 @@ 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 {} + +/** Request selectors must agree; authenticated provider selection is used only + * when the request supplies none. Neither memberships nor caches pick an org. */ +function selectedOrganizationForAuthentication(req: Request, providerOrg?: string): string | null { + const selectors = [ + 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 && value !== null); + 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 ?? providerOrg ?? 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): boolean { + 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 +817,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)) 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 +831,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 +842,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 +849,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 +910,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 +1090,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 +1109,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 +1141,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 +1428,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); } } @@ -1919,7 +1802,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)) 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 +1816,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 +1827,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,7 +1834,7 @@ 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); @@ -1979,16 +1867,12 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti 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 @@ -2119,24 +2003,24 @@ 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) { + if (sendAuthorizationStateError(error, res)) return; // Silently fail for optional auth logger.debug({ err: error }, 'Optional auth failed'); } 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/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/user-authorization-snapshot.test.ts b/server/tests/integration/user-authorization-snapshot.test.ts new file mode 100644 index 00000000000..52e4d037e17 --- /dev/null +++ b/server/tests/integration/user-authorization-snapshot.test.ts @@ -0,0 +1,354 @@ +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 { 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]; + +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 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 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' }, + }); + 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 }, + ])('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' } }, + ]; + 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 = pool.query.bind(pool); + 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(pool, 'query').mockImplementationOnce(async (...args: any[]) => { + const result = await (actualQuery as (...queryArgs: any[]) => Promise)(...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(pool, 'query').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(pool, 'query').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, + 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); + }); +}); diff --git a/server/tests/unit/addie-admin-global-auth.test.ts b/server/tests/unit/addie-admin-global-auth.test.ts index 38fa4c89bee..158e327fc0b 100644 --- a/server/tests/unit/addie-admin-global-auth.test.ts +++ b/server/tests/unit/addie-admin-global-auth.test.ts @@ -130,9 +130,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, + 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..d648f7ed07f 100644 --- a/server/tests/unit/authorization-epoch-session.test.ts +++ b/server/tests/unit/authorization-epoch-session.test.ts @@ -1,161 +1,294 @@ -/** - * 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 { 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(), })); - 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, })); +import { AuthorizationSnapshotUnavailableError } from '../../src/db/user-authorization-snapshot-db.js'; +import { invalidateBanCache, optionalAuth, requireAuth, stopAuthTimers } from '../../src/middleware/auth.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 }, + 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.loadSealedSession.mockReturnValue({ authenticate: mocks.authenticate, refresh: vi.fn() }); + mocks.verifyWorkOSJWT.mockResolvedValue({ sub: AUTHENTICATED_ID, email: PROVIDER_USER.email, isM2M: false }); +}); +afterAll(stopAuthTimers); -describe('persisted authorization epoch gates the session cache', () => { - 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: SESSION_USER, - accessToken: 'access-token', - }); - mocks.loadSealedSession.mockReturnValue({ - authenticate: mocks.authenticate, - refresh: vi.fn(), - }); - mocks.getAuthorizationFingerprint.mockResolvedValue('user_epoch_primary:1'); - }); - - it('serves the cached session while the fingerprint is unchanged', async () => { - const cookie = `sealed-unchanged-${Date.now()}`; +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 requireAuth(makeRequest(cookie), makeResponse(), next); - await requireAuth(makeRequest(cookie), makeResponse(), next); - + await middleware(first, response(), next); + mocks.loadAuthorizationSnapshot.mockResolvedValue(snapshot({ authorizationEpoch: '2' })); + await middleware(second, response(), next); expect(next).toHaveBeenCalledTimes(2); - // Second request came from cache — the sealed session was unsealed once. - expect(mocks.authenticate).toHaveBeenCalledTimes(1); + 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 }, + })); + 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'); +}); - it('re-validates when an identity-binding change moved the fingerprint', async () => { - const cookie = `sealed-bumped-${Date.now()}`; - const next = vi.fn() as NextFunction; - - await requireAuth(makeRequest(cookie), makeResponse(), next); - expect(mocks.authenticate).toHaveBeenCalledTimes(1); - - // A binding mutation bumped the credential's epoch on another instance. - mocks.getAuthorizationFingerprint.mockResolvedValue('user_epoch_primary:2'); - - await requireAuth(makeRequest(cookie), makeResponse(), next); - - expect(next).toHaveBeenCalledTimes(2); - expect(mocks.authenticate).toHaveBeenCalledTimes(2); +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(); }); +}); - 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; +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()); - await requireAuth(makeRequest(cookie), makeResponse(), next); - expect(mocks.authenticate).toHaveBeenCalledTimes(1); + 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); - mocks.getAuthorizationFingerprint.mockRejectedValueOnce(new Error('db down')); + 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); - await requireAuth(makeRequest(cookie), makeResponse(), next); + 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(); - expect(next).toHaveBeenCalledTimes(2); - expect(mocks.authenticate).toHaveBeenCalledTimes(2); + 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); }); }); diff --git a/server/tests/unit/resolve-user-org-authorization.test.ts b/server/tests/unit/resolve-user-org-authorization.test.ts index 8d4aa180609..75912943f00 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 }, + 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/working-group-admin-global-auth.test.ts b/server/tests/unit/working-group-admin-global-auth.test.ts index 7c1623375bc..b311dc8e46d 100644 --- a/server/tests/unit/working-group-admin-global-auth.test.ts +++ b/server/tests/unit/working-group-admin-global-auth.test.ts @@ -136,9 +136,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, + first_name: 'SSO', last_name: 'Admin', grant_id: null, + }], rowCount: 1, }); } From 59cfbc3ea2c4f33b951aa94c2d09614fdc0571b8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 13 Sep 2026 14:30:21 +0000 Subject: [PATCH 2/7] fix(auth): bound snapshots and reject conflicting credentials --- .../src/db/user-authorization-snapshot-db.ts | 28 +- server/src/middleware/auth.ts | 78 +++-- .../user-authorization-snapshot.test.ts | 36 ++- .../unit/addie-admin-global-auth.test.ts | 1 + .../unit/authorization-epoch-session.test.ts | 273 +++++++++++++++++- .../unit/network-health-security.test.ts | 24 +- ...ser-authorization-snapshot-timeout.test.ts | 125 ++++++++ .../working-group-admin-global-auth.test.ts | 1 + 8 files changed, 527 insertions(+), 39 deletions(-) create mode 100644 server/tests/unit/user-authorization-snapshot-timeout.test.ts diff --git a/server/src/db/user-authorization-snapshot-db.ts b/server/src/db/user-authorization-snapshot-db.ts index 74d32facc08..d6a4f7cda30 100644 --- a/server/src/db/user-authorization-snapshot-db.ts +++ b/server/src/db/user-authorization-snapshot-db.ts @@ -1,4 +1,6 @@ -import { getPool } from './client.js'; +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 { @@ -48,11 +50,29 @@ interface SnapshotRow { 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); + 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 pool is the primary - * database; a connection to a recovering replica fails closed in this statement. + * 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. @@ -63,7 +83,7 @@ export async function loadAuthorizationSnapshot( ): Promise { const organizationId = selectedOrganizationId || null; try { - const result = await getPool().query( + 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, diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index a272ed5f9ac..285dad9bf9f 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -182,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; @@ -364,6 +372,7 @@ export async function validateWorkOSBearerJWT(req: Request): Promise value !== undefined && value !== null); + ].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 ?? providerOrg ?? null; + return ids.values().next().value ?? null; } /** Hydrate a new request object; never mutate the provider object in a cache. */ @@ -746,7 +757,12 @@ async function hydrateDevUser(user: WorkOSUser): Promise { } } -function sendAuthorizationStateError(error: unknown, res: Response): boolean { +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' }); @@ -821,7 +837,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio try { jwtAuth = await validateWorkOSBearerJWT(req); } catch (error) { - if (sendAuthorizationStateError(error, res)) return; + if (sendAuthorizationStateError(error, res, true)) return; throw error; } if (jwtAuth) { @@ -1739,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) @@ -1806,7 +1819,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti try { jwtAuth = await validateWorkOSBearerJWT(req); } catch (error) { - if (sendAuthorizationStateError(error, res)) return; + if (sendAuthorizationStateError(error, res, true)) return; throw error; } if (jwtAuth) { @@ -1841,14 +1854,16 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti 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); if (!sessionCookie) { + if (req.headers.authorization !== undefined || req.cookies?.['wos-session'] !== undefined) { + return res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' }); + } return next(); } @@ -1862,7 +1877,7 @@ 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); } @@ -1903,6 +1918,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; @@ -1920,6 +1938,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; } @@ -1941,6 +1960,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); @@ -1954,6 +1976,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 { @@ -1963,6 +1986,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' }); } } } @@ -1976,6 +2000,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) { @@ -2020,9 +2046,9 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti req.accessToken = result.accessToken; } } catch (error) { - if (sendAuthorizationStateError(error, res)) return; - // 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/tests/integration/user-authorization-snapshot.test.ts b/server/tests/integration/user-authorization-snapshot.test.ts index 52e4d037e17..dd00ef27621 100644 --- a/server/tests/integration/user-authorization-snapshot.test.ts +++ b/server/tests/integration/user-authorization-snapshot.test.ts @@ -1,6 +1,7 @@ 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, @@ -250,7 +251,7 @@ describe('primary database authorization snapshots', () => { const readBarrier = new Promise(resolve => { readCompleted = resolve; }); const deliveryBarrier = new Promise(resolve => { releaseRead = resolve; }); let pendingRead: Promise | undefined; - const actualQuery = pool.query.bind(pool); + const actualQuery = database.queryWithTimeout; try { await writer.query('BEGIN'); await link(primaryId, secondaryId, writer); @@ -262,8 +263,8 @@ describe('primary database authorization snapshots', () => { // 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(pool, 'query').mockImplementationOnce(async (...args: any[]) => { - const result = await (actualQuery as (...queryArgs: any[]) => Promise)(...args); + const readSpy = vi.spyOn(database, 'queryWithTimeout').mockImplementationOnce(async (...args) => { + const result = await actualQuery(...args); readCompleted(); await deliveryBarrier; return result; @@ -314,7 +315,7 @@ describe('primary database authorization snapshots', () => { it('fails unavailable on database errors instead of replaying a warm snapshot', async () => { await snapshot(PERSONAL_ID, PERSONAL_ORG); - vi.spyOn(pool, 'query').mockRejectedValueOnce(new Error('private database connection details')); + vi.spyOn(database, 'queryWithTimeout').mockRejectedValueOnce(new Error('private database connection details')); await expect(loadAuthorizationSnapshot(PERSONAL_ID, PERSONAL_ORG)) .rejects.toThrow(new AuthorizationSnapshotUnavailableError()); }); @@ -326,7 +327,7 @@ describe('primary database authorization snapshots', () => { 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(pool, 'query').mockResolvedValueOnce({ + const readSpy = vi.spyOn(database, 'queryWithTimeout').mockResolvedValueOnce({ rows: [{ in_recovery: true, authenticated_user_id: hasUser ? primary.authenticatedUserId : null, @@ -351,4 +352,29 @@ describe('primary database authorization snapshots', () => { .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/addie-admin-global-auth.test.ts b/server/tests/unit/addie-admin-global-auth.test.ts index 158e327fc0b..3be925c3782 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', () => ({ diff --git a/server/tests/unit/authorization-epoch-session.test.ts b/server/tests/unit/authorization-epoch-session.test.ts index d648f7ed07f..93148d7e0a7 100644 --- a/server/tests/unit/authorization-epoch-session.test.ts +++ b/server/tests/unit/authorization-epoch-session.test.ts @@ -6,6 +6,7 @@ import type { AuthorizationSnapshot } from '../../src/db/user-authorization-snap const mocks = vi.hoisted(() => ({ 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.DEV_USER_EMAIL = ''; @@ -36,6 +37,11 @@ vi.mock('../../src/auth/workos-jwt.js', () => ({ vi.mock('../../src/db/client.js', () => ({ 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'; @@ -74,9 +80,274 @@ beforeEach(() => { invalidateBanCache('user', AUTHENTICATED_ID); mocks.checkPlatformBan.mockResolvedValue({ banned: false }); mocks.authenticate.mockResolvedValue({ authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'access-token' }); - mocks.loadSealedSession.mockReturnValue({ authenticate: mocks.authenticate, refresh: vi.fn() }); + 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 }); }); + +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(() => { + 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: { ...PROVIDER_USER }, accessToken: 'access-token', organizationId: providerOrganization, + }); + mocks.verifyWorkOSJWT.mockResolvedValue({ + sub: AUTHENTICATED_ID, email: PROVIDER_USER.email, isM2M: false, orgId: providerOrganization, + }); + 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(); + }); +}); + +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(); + } + + 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(); + }); + + 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.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); + }); + + 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(); + }); + + 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(); + }); + + 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); + }); + + 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.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); + + 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); + }); + + 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); + }); + + 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([ diff --git a/server/tests/unit/network-health-security.test.ts b/server/tests/unit/network-health-security.test.ts index 94f35fa58c4..38185b9bf59 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, + 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/user-authorization-snapshot-timeout.test.ts b/server/tests/unit/user-authorization-snapshot-timeout.test.ts new file mode 100644 index 00000000000..b798eb59e37 --- /dev/null +++ b/server/tests/unit/user-authorization-snapshot-timeout.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { queryWithTimeout } from '../../src/db/client.js'; +import { + AuthorizationSnapshotUnavailableError, + loadAuthorizationSnapshot, +} 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, + 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(second).toEqual([first[0], first[1], 1_300]); + 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 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); + }); +}); 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 b311dc8e46d..c09ff1fb344 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 () => { From 074ccade6bc1b7b11accf925f6e11be2cc089ad6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 13 Sep 2026 14:43:13 +0000 Subject: [PATCH 3/7] test(auth): document optional credential guard and cover HTTP failures --- server/src/middleware/auth.ts | 1 + .../unit/authorization-epoch-session.test.ts | 76 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 285dad9bf9f..23b660ba39c 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -1860,6 +1860,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti const sessionCookie = extractSealedSession(req); + // codeql[js/user-controlled-bypass] - optional auth permits anonymous access only when both credentials are absent; presented credentials fail closed if (!sessionCookie) { if (req.headers.authorization !== undefined || req.cookies?.['wos-session'] !== undefined) { return res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' }); diff --git a/server/tests/unit/authorization-epoch-session.test.ts b/server/tests/unit/authorization-epoch-session.test.ts index 93148d7e0a7..691d62c18a1 100644 --- a/server/tests/unit/authorization-epoch-session.test.ts +++ b/server/tests/unit/authorization-epoch-session.test.ts @@ -1,5 +1,8 @@ /** Provider caches must never become identity, organization, or epoch authority. */ import type { NextFunction, Request, Response } from 'express'; +import express from 'express'; +import cookieParser from 'cookie-parser'; +import supertest from 'supertest'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; @@ -44,6 +47,7 @@ vi.mock('../../src/db/session-refresh-db.js', () => ({ })); 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'; const AUTHENTICATED_ID = 'user_epoch_authenticated'; const PROVIDER_USER = { @@ -563,3 +567,75 @@ describe.each([['required', requireAuth], ['optional', optionalAuth]] as const)( expect(mocks.verifyWorkOSJWT).toHaveBeenCalledTimes(1); }); }); + +describe('optional authentication HTTP route boundary', () => { + function route() { + const app = express(); + // Exercise the real HTTP cookie parser, including an explicitly empty cookie. + // This read-only route has the same middleware chain for anonymous and authenticated callers. + app.use(cookieParser()); + 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 { app, handler } = route(); + const result = await supertest(app).get('/optional-auth-test') + .set('Authorization', authorization) + .set('Cookie', `wos-session=otherwise-valid-${++sequence}`); + 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(); + 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(); + 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 { app, handler } = route(); + 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=unavailable-route-${++sequence}`); + const result = await httpRequest; + expect(result.status).toBe(503); + expect(handler).not.toHaveBeenCalled(); + expect(mocks.loadAuthorizationSnapshot).toHaveBeenCalledOnce(); + }); +}); From 9b9010f825bd0b47ad39c3753a014e00542db740 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 13 Sep 2026 14:56:17 +0000 Subject: [PATCH 4/7] fix(auth): make optional credential guard an explicit early return --- server/src/middleware/auth.ts | 11 ++++---- .../unit/authorization-epoch-session.test.ts | 28 +++++++++++-------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 23b660ba39c..c99c8e1ef0f 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -1860,12 +1860,13 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti const sessionCookie = extractSealedSession(req); - // codeql[js/user-controlled-bypass] - optional auth permits anonymous access only when both credentials are absent; presented credentials fail closed + // 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) { - if (req.headers.authorization !== undefined || req.cookies?.['wos-session'] !== undefined) { - return res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' }); - } - 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 { diff --git a/server/tests/unit/authorization-epoch-session.test.ts b/server/tests/unit/authorization-epoch-session.test.ts index 691d62c18a1..f99feded240 100644 --- a/server/tests/unit/authorization-epoch-session.test.ts +++ b/server/tests/unit/authorization-epoch-session.test.ts @@ -1,7 +1,6 @@ /** Provider caches must never become identity, organization, or epoch authority. */ import type { NextFunction, Request, Response } from 'express'; import express from 'express'; -import cookieParser from 'cookie-parser'; import supertest from 'supertest'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; @@ -569,11 +568,16 @@ describe.each([['required', requireAuth], ['optional', optionalAuth]] as const)( }); describe('optional authentication HTTP route boundary', () => { - function route() { + function route(parsedCookies: Record = {}) { const app = express(); - // Exercise the real HTTP cookie parser, including an explicitly empty cookie. - // This read-only route has the same middleware chain for anonymous and authenticated callers. - app.use(cookieParser()); + // 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) }); @@ -594,10 +598,11 @@ describe('optional authentication HTTP route boundary', () => { 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 { app, handler } = route(); + 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=otherwise-valid-${++sequence}`); + .set('Cookie', `wos-session=${cookie}`); expect(result.status).toBe(401); expect(handler).not.toHaveBeenCalled(); expect(mocks.loadSealedSession).not.toHaveBeenCalled(); @@ -607,7 +612,7 @@ describe('optional authentication HTTP route boundary', () => { 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(); + 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'); @@ -619,7 +624,7 @@ describe('optional authentication HTTP route boundary', () => { 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(); + 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(); @@ -629,10 +634,11 @@ describe('optional authentication HTTP route boundary', () => { 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 { app, handler } = route(); + 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=unavailable-route-${++sequence}`); + else httpRequest.set('Cookie', `wos-session=${cookie}`); const result = await httpRequest; expect(result.status).toBe(503); expect(handler).not.toHaveBeenCalled(); From 7b9876d4670e1518dfe15445e1746e4f081e65cb Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 01:02:07 +0000 Subject: [PATCH 5/7] fix(db): bound authorization snapshot pool checkout --- server/src/db/client.ts | 67 ++++++++++- .../src/db/user-authorization-snapshot-db.ts | 6 +- server/tests/unit/db-client-checkout.test.ts | 110 ++++++++++++++++++ ...ser-authorization-snapshot-timeout.test.ts | 3 +- 4 files changed, 181 insertions(+), 5 deletions(-) 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/user-authorization-snapshot-db.ts b/server/src/db/user-authorization-snapshot-db.ts index d6a4f7cda30..263982e1281 100644 --- a/server/src/db/user-authorization-snapshot-db.ts +++ b/server/src/db/user-authorization-snapshot-db.ts @@ -57,7 +57,11 @@ async function querySnapshot(statement: string, parameters: [string, string | nu const remainingMs = deadlineMs - Date.now(); if (remainingMs <= 0) throw new AuthorizationSnapshotUnavailableError(); try { - const result = await queryWithTimeout(statement, parameters, remainingMs); + 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) { diff --git a/server/tests/unit/db-client-checkout.test.ts b/server/tests/unit/db-client-checkout.test.ts index b813b903fb6..dc097bc9db9 100644 --- a/server/tests/unit/db-client-checkout.test.ts +++ b/server/tests/unit/db-client-checkout.test.ts @@ -311,6 +311,116 @@ 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, + 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/user-authorization-snapshot-timeout.test.ts b/server/tests/unit/user-authorization-snapshot-timeout.test.ts index b798eb59e37..aa295be55f0 100644 --- a/server/tests/unit/user-authorization-snapshot-timeout.test.ts +++ b/server/tests/unit/user-authorization-snapshot-timeout.test.ts @@ -65,7 +65,8 @@ describe('authorization snapshot query deadline and connection retry', () => { expect(boundedQuery).toHaveBeenCalledTimes(2); const [first, second] = boundedQuery.mock.calls; expect(first[2]).toBe(2_000); - expect(second).toEqual([first[0], first[1], 1_300]); + 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]); }); From 77608c1fea82e2f971f5e87839b296cade9b61b2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 13 Sep 2026 15:15:23 +0000 Subject: [PATCH 6/7] fix(auth): bind committee mutations to credentials --- scripts/addie-tool-surface-budget.json | 16 +- server/src/addie/bolt-app.ts | 6 +- server/src/addie/config-version.ts | 2 +- .../tool-surface-inventory.generated.json | 158 ++-- .../src/addie/mcp/committee-leader-tools.ts | 303 +++++-- server/src/routes/addie-chat.ts | 25 +- server/src/routes/tavus.ts | 30 +- .../src/services/committee-leader-mutation.ts | 839 ++++++++++++++++++ ...ttee-leader-mutation-authorization.test.ts | 744 ++++++++++++++++ .../unit/addie-chat-org-selection.test.ts | 118 ++- .../unit/addie/tavus-tool-selection.test.ts | 50 +- ...-leader-tool-handler-authorization.test.ts | 146 +++ .../unit/tavus-session-guidance-route.test.ts | 73 ++ 13 files changed, 2316 insertions(+), 194 deletions(-) create mode 100644 server/src/services/committee-leader-mutation.ts create mode 100644 server/tests/integration/committee-leader-mutation-authorization.test.ts create mode 100644 server/tests/unit/committee-leader-tool-handler-authorization.test.ts diff --git a/scripts/addie-tool-surface-budget.json b/scripts/addie-tool-surface-budget.json index 879ea6528cb..1d824a3f8e4 100644 --- a/scripts/addie-tool-surface-budget.json +++ b/scripts/addie-tool-surface-budget.json @@ -1,7 +1,7 @@ { "schema_version": 4, "profile_ids_sha256": "5c4b3378b3d01fbcf75aeb0a40a6b83f460060fbc3e7ec37dc8444647d804faa", - "profile_contract_sha256": "7dc421ca0ff06e1c4f52f35616b16a5be2da709f2ae25af507b61f509d0fbe4f", + "profile_contract_sha256": "3ceeacf8603f33b64c98eaa8f8988fc3fb0cb6107224f9e1e65c1b6e7096e207", "baseline": { "id": "2026-08-26-pre-domain-consolidation", "metrics": { @@ -41,7 +41,7 @@ "maximum_slack_public_tools": 204, "maximum_slack_member_schema_bytes": 32732, "maximum_slack_admin_schema_bytes": 35026, - "maximum_slack_public_schema_bytes": 162566, + "maximum_slack_public_schema_bytes": 162954, "web_anonymous_tools": 13, "web_authenticated_admin_tools": 35 }, @@ -50,17 +50,17 @@ "mcp_chat:anonymous": { "custom_tool_count": 13, "wire_schema_bytes": 9196, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, "official_docs_replay:evaluation_only": { "custom_tool_count": 2, "wire_schema_bytes": 1873, "maximum_provider_tool_count": 0, "maximum_provider_tool_wire_bytes": 2 }, "slack_bolt:admin_dm": { "custom_tool_count": 35, "wire_schema_bytes": 35026, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, - "slack_bolt:admin_working_group": { "custom_tool_count": 221, "wire_schema_bytes": 175240, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, + "slack_bolt:admin_working_group": { "custom_tool_count": 221, "wire_schema_bytes": 175627, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, "slack_bolt:member_dm": { "custom_tool_count": 33, "wire_schema_bytes": 32732, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, - "slack_bolt:private_channel_admin": { "custom_tool_count": 221, "wire_schema_bytes": 175240, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, - "slack_bolt:private_channel_member": { "custom_tool_count": 155, "wire_schema_bytes": 132799, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, + "slack_bolt:private_channel_admin": { "custom_tool_count": 221, "wire_schema_bytes": 175627, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, + "slack_bolt:private_channel_member": { "custom_tool_count": 155, "wire_schema_bytes": 133187, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, "slack_bolt:private_mention_admin": { "custom_tool_count": 35, "wire_schema_bytes": 34783, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, "slack_bolt:private_mention_member": { "custom_tool_count": 33, "wire_schema_bytes": 32489, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, - "slack_bolt:public_channel_admin": { "custom_tool_count": 204, "wire_schema_bytes": 162566, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, - "slack_bolt:public_channel_member": { "custom_tool_count": 150, "wire_schema_bytes": 129526, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, + "slack_bolt:public_channel_admin": { "custom_tool_count": 204, "wire_schema_bytes": 162954, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, + "slack_bolt:public_channel_member": { "custom_tool_count": 150, "wire_schema_bytes": 129914, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, "slack_bolt:public_mention_admin": { "custom_tool_count": 32, "wire_schema_bytes": 32016, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, "slack_bolt:public_mention_member": { "custom_tool_count": 32, "wire_schema_bytes": 32016, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, - "slack_bolt:system_channel_admin": { "custom_tool_count": 221, "wire_schema_bytes": 175240, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, + "slack_bolt:system_channel_admin": { "custom_tool_count": 221, "wire_schema_bytes": 175627, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, "slack_bolt_reaction:admin": { "custom_tool_count": 35, "wire_schema_bytes": 34783, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, "slack_bolt_reaction:member": { "custom_tool_count": 33, "wire_schema_bytes": 32489, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, "slack_bolt_reaction:public_channel": { "custom_tool_count": 32, "wire_schema_bytes": 32016, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, diff --git a/server/src/addie/bolt-app.ts b/server/src/addie/bolt-app.ts index 380694fe592..33608c4473d 100644 --- a/server/src/addie/bolt-app.ts +++ b/server/src/addie/bolt-app.ts @@ -1409,7 +1409,11 @@ async function createUserScopedTools( } // Add committee leader tools (co-leader management, self-enforcing permissions) - const committeeLeaderHandlers = createCommitteeLeaderToolHandlers(memberContext, slackUserId); + const committeeLeaderHandlers = createCommitteeLeaderToolHandlers( + memberContext, + slackUserId, + { surface: 'slack' }, + ); allTools.push(...COMMITTEE_LEADER_TOOLS); for (const [name, handler] of committeeLeaderHandlers) { allHandlers.set(name, handler); diff --git a/server/src/addie/config-version.ts b/server/src/addie/config-version.ts index f5ee2a33a14..4c0a4b3f56c 100644 --- a/server/src/addie/config-version.ts +++ b/server/src/addie/config-version.ts @@ -30,7 +30,7 @@ import { loadRules, loadResponseStyle } from './rules/index.js'; * Format: YYYY.MM.N where N is incremented for multiple changes in a month * Example: 2025.01.1, 2025.01.2, 2025.02.1 */ -export const CODE_VERSION = '2026.09.55'; +export const CODE_VERSION = '2026.09.56'; // Types export interface ConfigVersion { diff --git a/server/src/addie/generated/tool-surface-inventory.generated.json b/server/src/addie/generated/tool-surface-inventory.generated.json index b2c88c01946..f151d8af527 100644 --- a/server/src/addie/generated/tool-surface-inventory.generated.json +++ b/server/src/addie/generated/tool-surface-inventory.generated.json @@ -70,9 +70,9 @@ "server/src/addie/tool-wire-shape.ts": "4c5bcb95c32164b153c0f9d36648ea9f885e1df05e9796fdd71684c56162a81e", "server/src/addie/prompt-assembly.ts": "6c002ae5a52cdd26e13f424f6e00757dff163b3b273a1b4ab069fdb86f4f375f", "server/src/addie/register-baseline-tools.ts": "52893917fa19fa8904e1667857a7c4bfc2dc90bd3341e60d1ff45a40400d17aa", - "server/src/addie/bolt-app.ts": "cb1b76808654a295064cbd8b7dc580e17b9c4a54f80e882543cff5c15086632f", - "server/src/routes/addie-chat.ts": "039ac44bf2768512b0cf5e6cf24713edba2ab95b118153bfc6b58fcc228f8d49", - "server/src/routes/tavus.ts": "17321c275f89de1ec3e4996c6d7271bf252f7ff1ab1494dad820b090065d0da4", + "server/src/addie/bolt-app.ts": "383f5f69c13b291b929c64a138829d6092f3d79fd04fa3f5d8ba228a609bc086", + "server/src/routes/addie-chat.ts": "ce09bbc2270936ae2bfb35ca72bed5192a1976ab1fe682a4b64469cf61cdecc7", + "server/src/routes/tavus.ts": "96c8f243badecbf4c42d997007594467c4a8a7b25b099d75d0e29693522eb3b6", "server/src/addie/email-conversation-handler.ts": "d6cf6d3c8621a85c69c6b22b86e6aa78f03b0d532ac8d5162fd40ec6c869f70c", "server/src/mcp/chat-tool.ts": "a6bbde38fda11337a899fc92b8cabc3617377ae7c648609f34fae098f138f87e", "server/src/addie/jobs/shadow-replay-cohort.ts": "316d0764cd65e4c631d6efef7d090fd46a6a8fff22d4c917a40b44a7a69eee1f", @@ -3032,7 +3032,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 11301, + "wire_schema_bytes": 11689, "tool_reference_bytes": 4981, "tool_reference_sha256": "a74c5c2bd2d65d9f5639a6aad516e33599b92edba4bf9cc24ff468efc9889c51", "overridden_tool_count": 0, @@ -3052,7 +3052,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "bbe7d7c41eafa947c0f6456ae91b140fe2fb526a765ada057940d8283bad2d3e", - "wire_schema_sha256": "e5ffabd8892dda150680b01bebb729514182e0addc159a3b4b9c0cf5b68ef443" + "wire_schema_sha256": "7c9a69d76d45adc46d4aaff25f99027000863aef5d3b242c3cf9f679c589a95c" }, { "id": "slack_bolt:admin_dm:routed:committee_event_planning", @@ -3163,7 +3163,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 17331, + "wire_schema_bytes": 17719, "tool_reference_bytes": 5272, "tool_reference_sha256": "8f6e40ff2049c4b545422207ee004ad7e0d78caba7b71253994e0ebdf73978e5", "overridden_tool_count": 0, @@ -3188,7 +3188,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "04ec5e8b84cc7c87cf26efc8cd7c37398baefb992eb687800a7c461a0f74b752", - "wire_schema_sha256": "2daa248d5815482efb2b9346512c42202b7983c70be7d3a698c0fdc504090eb4", + "wire_schema_sha256": "de01fd1789668c5375c4236e0b4c63bbedf4ad36db3d896752317bf92b9f5814", "target_exception_category": "explicit_multi_step_workflow" }, { @@ -5926,7 +5926,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 175239, + "wire_schema_bytes": 175627, "tool_reference_bytes": 40307, "tool_reference_sha256": "de3bb91fc152a573a8ba16b230aa4fc0eeddf9753bf81b6f4a7ed7c0310b3e8f", "overridden_tool_count": 11, @@ -6156,7 +6156,7 @@ "save_learner_feedback" ], "ordered_tool_names_sha256": "3f29365376bb89b05f2ffe93da368202f0a56c62857541a7d9b8506e0ec8fee8", - "wire_schema_sha256": "c5f0128811b46511d9cb68f84b14167c277bfd99aace9118b648d2961b952d96", + "wire_schema_sha256": "f4db4ac8fed492665d636c80fc2bf463ee9cf16c5139db204c4384fce9144646", "target_exception_category": "synthetic_all_sets_maximum" }, { @@ -6458,7 +6458,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 11301, + "wire_schema_bytes": 11689, "tool_reference_bytes": 4981, "tool_reference_sha256": "a74c5c2bd2d65d9f5639a6aad516e33599b92edba4bf9cc24ff468efc9889c51", "overridden_tool_count": 0, @@ -6478,7 +6478,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "bbe7d7c41eafa947c0f6456ae91b140fe2fb526a765ada057940d8283bad2d3e", - "wire_schema_sha256": "e5ffabd8892dda150680b01bebb729514182e0addc159a3b4b9c0cf5b68ef443" + "wire_schema_sha256": "7c9a69d76d45adc46d4aaff25f99027000863aef5d3b242c3cf9f679c589a95c" }, { "id": "slack_bolt:admin_working_group:committee_event_planning", @@ -6589,7 +6589,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 17331, + "wire_schema_bytes": 17719, "tool_reference_bytes": 5272, "tool_reference_sha256": "8f6e40ff2049c4b545422207ee004ad7e0d78caba7b71253994e0ebdf73978e5", "overridden_tool_count": 0, @@ -6614,7 +6614,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "04ec5e8b84cc7c87cf26efc8cd7c37398baefb992eb687800a7c461a0f74b752", - "wire_schema_sha256": "2daa248d5815482efb2b9346512c42202b7983c70be7d3a698c0fdc504090eb4", + "wire_schema_sha256": "de01fd1789668c5375c4236e0b4c63bbedf4ad36db3d896752317bf92b9f5814", "target_exception_category": "explicit_multi_step_workflow" }, { @@ -9138,7 +9138,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 9008, + "wire_schema_bytes": 9396, "tool_reference_bytes": 4913, "tool_reference_sha256": "6c3e8581decba525396c1e53df2d6afaf7aa6e9b5bf80df401e63e1cda3bc0ea", "overridden_tool_count": 0, @@ -9156,7 +9156,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "063596a30c0f4e99cfdd77779d92a7145e459caff78c9dff3604043a9ae8c59c", - "wire_schema_sha256": "ab974565c5b82fadcde3f73292631edbe36a14535a1c668bb7dc19112b30549d" + "wire_schema_sha256": "64f071c34411113ee7b7aaa9a894156095dfb83f2b0023d762bb74fc5de0210d" }, { "id": "slack_bolt:member_dm:routed:committee_event_planning", @@ -9263,7 +9263,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 15038, + "wire_schema_bytes": 15426, "tool_reference_bytes": 5204, "tool_reference_sha256": "73a64178cee70c326c45c27854fa4b29f61c9dc4203c20deb8cdbde7d932d29b", "overridden_tool_count": 0, @@ -9286,7 +9286,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "3ef51ef6c23dc0bb939300d94c013762dcc27966aecdaaa1260aa3786c167cdc", - "wire_schema_sha256": "b30205b328e7ca8969df567373277d78f4729864a0c27005b635a125e754c8dc", + "wire_schema_sha256": "36883748f2d46318f0b284d2c05b1eeec5aeae054ff4087877bacf91a9abf435", "target_exception_category": "explicit_multi_step_workflow" }, { @@ -11877,7 +11877,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 175239, + "wire_schema_bytes": 175627, "tool_reference_bytes": 40307, "tool_reference_sha256": "de3bb91fc152a573a8ba16b230aa4fc0eeddf9753bf81b6f4a7ed7c0310b3e8f", "overridden_tool_count": 11, @@ -12107,7 +12107,7 @@ "save_learner_feedback" ], "ordered_tool_names_sha256": "3f29365376bb89b05f2ffe93da368202f0a56c62857541a7d9b8506e0ec8fee8", - "wire_schema_sha256": "c5f0128811b46511d9cb68f84b14167c277bfd99aace9118b648d2961b952d96", + "wire_schema_sha256": "f4db4ac8fed492665d636c80fc2bf463ee9cf16c5139db204c4384fce9144646", "target_exception_category": "synthetic_all_sets_maximum" }, { @@ -12409,7 +12409,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 11301, + "wire_schema_bytes": 11689, "tool_reference_bytes": 4981, "tool_reference_sha256": "a74c5c2bd2d65d9f5639a6aad516e33599b92edba4bf9cc24ff468efc9889c51", "overridden_tool_count": 0, @@ -12429,7 +12429,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "bbe7d7c41eafa947c0f6456ae91b140fe2fb526a765ada057940d8283bad2d3e", - "wire_schema_sha256": "e5ffabd8892dda150680b01bebb729514182e0addc159a3b4b9c0cf5b68ef443" + "wire_schema_sha256": "7c9a69d76d45adc46d4aaff25f99027000863aef5d3b242c3cf9f679c589a95c" }, { "id": "slack_bolt:private_channel_admin:committee_event_planning", @@ -12540,7 +12540,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 17331, + "wire_schema_bytes": 17719, "tool_reference_bytes": 5272, "tool_reference_sha256": "8f6e40ff2049c4b545422207ee004ad7e0d78caba7b71253994e0ebdf73978e5", "overridden_tool_count": 0, @@ -12565,7 +12565,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "04ec5e8b84cc7c87cf26efc8cd7c37398baefb992eb687800a7c461a0f74b752", - "wire_schema_sha256": "2daa248d5815482efb2b9346512c42202b7983c70be7d3a698c0fdc504090eb4", + "wire_schema_sha256": "de01fd1789668c5375c4236e0b4c63bbedf4ad36db3d896752317bf92b9f5814", "target_exception_category": "explicit_multi_step_workflow" }, { @@ -14482,7 +14482,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 132799, + "wire_schema_bytes": 133187, "tool_reference_bytes": 35659, "tool_reference_sha256": "74717adf1dad240fa68a4bff04a7c1509ecf87374aaa1fac63ba7305bf6bf75e", "overridden_tool_count": 11, @@ -14646,7 +14646,7 @@ "save_learner_feedback" ], "ordered_tool_names_sha256": "4fc973c9b78d787ca4756ed97ae615fdb6e138f4928329a9bda312060d614af9", - "wire_schema_sha256": "e5e7559c057267b33840124668aa19ae54cc30c8c6a12a8eaa19e50da6180478", + "wire_schema_sha256": "903f3903da3baeaf1925b264fe163ccbf57ee257f8ef958f0a3ed08e931543ef", "target_exception_category": "synthetic_all_sets_maximum" }, { @@ -14936,7 +14936,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 9008, + "wire_schema_bytes": 9396, "tool_reference_bytes": 4913, "tool_reference_sha256": "6c3e8581decba525396c1e53df2d6afaf7aa6e9b5bf80df401e63e1cda3bc0ea", "overridden_tool_count": 0, @@ -14954,7 +14954,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "063596a30c0f4e99cfdd77779d92a7145e459caff78c9dff3604043a9ae8c59c", - "wire_schema_sha256": "ab974565c5b82fadcde3f73292631edbe36a14535a1c668bb7dc19112b30549d" + "wire_schema_sha256": "64f071c34411113ee7b7aaa9a894156095dfb83f2b0023d762bb74fc5de0210d" }, { "id": "slack_bolt:private_channel_member:committee_event_planning", @@ -15061,7 +15061,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 15038, + "wire_schema_bytes": 15426, "tool_reference_bytes": 5204, "tool_reference_sha256": "73a64178cee70c326c45c27854fa4b29f61c9dc4203c20deb8cdbde7d932d29b", "overridden_tool_count": 0, @@ -15084,7 +15084,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "3ef51ef6c23dc0bb939300d94c013762dcc27966aecdaaa1260aa3786c167cdc", - "wire_schema_sha256": "b30205b328e7ca8969df567373277d78f4729864a0c27005b635a125e754c8dc", + "wire_schema_sha256": "36883748f2d46318f0b284d2c05b1eeec5aeae054ff4087877bacf91a9abf435", "target_exception_category": "explicit_multi_step_workflow" }, { @@ -18088,7 +18088,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 11301, + "wire_schema_bytes": 11689, "tool_reference_bytes": 4981, "tool_reference_sha256": "a74c5c2bd2d65d9f5639a6aad516e33599b92edba4bf9cc24ff468efc9889c51", "overridden_tool_count": 0, @@ -18108,7 +18108,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "bbe7d7c41eafa947c0f6456ae91b140fe2fb526a765ada057940d8283bad2d3e", - "wire_schema_sha256": "e5ffabd8892dda150680b01bebb729514182e0addc159a3b4b9c0cf5b68ef443" + "wire_schema_sha256": "7c9a69d76d45adc46d4aaff25f99027000863aef5d3b242c3cf9f679c589a95c" }, { "id": "slack_bolt:private_mention_admin:routed:committee_event_planning", @@ -18219,7 +18219,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 17331, + "wire_schema_bytes": 17719, "tool_reference_bytes": 5272, "tool_reference_sha256": "8f6e40ff2049c4b545422207ee004ad7e0d78caba7b71253994e0ebdf73978e5", "overridden_tool_count": 0, @@ -18244,7 +18244,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "04ec5e8b84cc7c87cf26efc8cd7c37398baefb992eb687800a7c461a0f74b752", - "wire_schema_sha256": "2daa248d5815482efb2b9346512c42202b7983c70be7d3a698c0fdc504090eb4", + "wire_schema_sha256": "de01fd1789668c5375c4236e0b4c63bbedf4ad36db3d896752317bf92b9f5814", "target_exception_category": "explicit_multi_step_workflow" }, { @@ -20562,7 +20562,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 9008, + "wire_schema_bytes": 9396, "tool_reference_bytes": 4913, "tool_reference_sha256": "6c3e8581decba525396c1e53df2d6afaf7aa6e9b5bf80df401e63e1cda3bc0ea", "overridden_tool_count": 0, @@ -20580,7 +20580,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "063596a30c0f4e99cfdd77779d92a7145e459caff78c9dff3604043a9ae8c59c", - "wire_schema_sha256": "ab974565c5b82fadcde3f73292631edbe36a14535a1c668bb7dc19112b30549d" + "wire_schema_sha256": "64f071c34411113ee7b7aaa9a894156095dfb83f2b0023d762bb74fc5de0210d" }, { "id": "slack_bolt:private_mention_member:routed:committee_event_planning", @@ -20687,7 +20687,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 15038, + "wire_schema_bytes": 15426, "tool_reference_bytes": 5204, "tool_reference_sha256": "73a64178cee70c326c45c27854fa4b29f61c9dc4203c20deb8cdbde7d932d29b", "overridden_tool_count": 0, @@ -20710,7 +20710,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "3ef51ef6c23dc0bb939300d94c013762dcc27966aecdaaa1260aa3786c167cdc", - "wire_schema_sha256": "b30205b328e7ca8969df567373277d78f4729864a0c27005b635a125e754c8dc", + "wire_schema_sha256": "36883748f2d46318f0b284d2c05b1eeec5aeae054ff4087877bacf91a9abf435", "target_exception_category": "explicit_multi_step_workflow" }, { @@ -23208,7 +23208,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 162566, + "wire_schema_bytes": 162954, "tool_reference_bytes": 38965, "tool_reference_sha256": "ad344a07b56eb179bdb20ad73e04026ba21b3d641a4d73d97b83b9c40260b757", "overridden_tool_count": 11, @@ -23421,7 +23421,7 @@ "save_learner_feedback" ], "ordered_tool_names_sha256": "994762fb69bac61435ef563bcc4ae6996f89006a6b897bae32b260896b12ec88", - "wire_schema_sha256": "c2ec0769efebd26fd0384206d8b70fd04e4fabe923407963a8c21072d5875a39", + "wire_schema_sha256": "50acbc566c99a5fee8f1ecb080a96b5155e813ca6f6214e8dac21d5b20c2d646", "target_exception_category": "synthetic_all_sets_maximum" }, { @@ -23705,7 +23705,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 8535, + "wire_schema_bytes": 8923, "tool_reference_bytes": 4895, "tool_reference_sha256": "8f7d150a82bbc380d9474e142cb5c2c79fac72fc29b4b0ebcc3db733f103118a", "overridden_tool_count": 0, @@ -23722,7 +23722,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "ee91735b103b525c1642fe968d8f2c22275dbe0f1939e14c5167c05da008bc5b", - "wire_schema_sha256": "fcb7ccfb35dd576c3cdd678fee47919b5e8cbfe971c66a9bf522b8801e6cd466" + "wire_schema_sha256": "414f28112535f30c1b25289b034c599123ade727232d6b2d72465022899cebfe" }, { "id": "slack_bolt:public_channel_admin:committee_event_planning", @@ -23827,7 +23827,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 14565, + "wire_schema_bytes": 14953, "tool_reference_bytes": 5186, "tool_reference_sha256": "e0f6e1854b5b3d4917e6d5d43621e1216417e2aa31ca060f983136a0ff977c95", "overridden_tool_count": 0, @@ -23849,7 +23849,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "63d3d934436b7b22c32804baf79a34407d7c63fd8b26eeae139925b4ef4cce40", - "wire_schema_sha256": "c0ca917fad14979f688ac44db872ae2c8af644854216fa2c7e7ccd7473ee42d5", + "wire_schema_sha256": "dc0992a9416c2553d37d443b317171124ce1a646acc27c12c65437a1331e62e1", "target_exception_category": "explicit_multi_step_workflow" }, { @@ -25653,7 +25653,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 129526, + "wire_schema_bytes": 129914, "tool_reference_bytes": 34787, "tool_reference_sha256": "2e53eb61045adf3313fdd1eadc58a4d31d8276151c91c531def8a5d6e742be34", "overridden_tool_count": 11, @@ -25812,7 +25812,7 @@ "save_learner_feedback" ], "ordered_tool_names_sha256": "84cac510d8fc1d54538414851a13522cd600507fa95cde28520cd3e2a38ea2c7", - "wire_schema_sha256": "55ec5bdb0ec799ff39002a00a76a6fe0b4f1e5f535149a666947d0baa914bc8d", + "wire_schema_sha256": "1f44b4c25c94a808b2ebdafbd859b783b8b9c85198f5500a95d7d567480e2294", "target_exception_category": "synthetic_all_sets_maximum" }, { @@ -26096,7 +26096,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 8535, + "wire_schema_bytes": 8923, "tool_reference_bytes": 4895, "tool_reference_sha256": "8f7d150a82bbc380d9474e142cb5c2c79fac72fc29b4b0ebcc3db733f103118a", "overridden_tool_count": 0, @@ -26113,7 +26113,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "ee91735b103b525c1642fe968d8f2c22275dbe0f1939e14c5167c05da008bc5b", - "wire_schema_sha256": "fcb7ccfb35dd576c3cdd678fee47919b5e8cbfe971c66a9bf522b8801e6cd466" + "wire_schema_sha256": "414f28112535f30c1b25289b034c599123ade727232d6b2d72465022899cebfe" }, { "id": "slack_bolt:public_channel_member:committee_event_planning", @@ -26218,7 +26218,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 14565, + "wire_schema_bytes": 14953, "tool_reference_bytes": 5186, "tool_reference_sha256": "e0f6e1854b5b3d4917e6d5d43621e1216417e2aa31ca060f983136a0ff977c95", "overridden_tool_count": 0, @@ -26240,7 +26240,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "63d3d934436b7b22c32804baf79a34407d7c63fd8b26eeae139925b4ef4cce40", - "wire_schema_sha256": "c0ca917fad14979f688ac44db872ae2c8af644854216fa2c7e7ccd7473ee42d5", + "wire_schema_sha256": "dc0992a9416c2553d37d443b317171124ce1a646acc27c12c65437a1331e62e1", "target_exception_category": "explicit_multi_step_workflow" }, { @@ -31761,7 +31761,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 175239, + "wire_schema_bytes": 175627, "tool_reference_bytes": 40307, "tool_reference_sha256": "de3bb91fc152a573a8ba16b230aa4fc0eeddf9753bf81b6f4a7ed7c0310b3e8f", "overridden_tool_count": 11, @@ -31991,7 +31991,7 @@ "save_learner_feedback" ], "ordered_tool_names_sha256": "3f29365376bb89b05f2ffe93da368202f0a56c62857541a7d9b8506e0ec8fee8", - "wire_schema_sha256": "c5f0128811b46511d9cb68f84b14167c277bfd99aace9118b648d2961b952d96", + "wire_schema_sha256": "f4db4ac8fed492665d636c80fc2bf463ee9cf16c5139db204c4384fce9144646", "target_exception_category": "synthetic_all_sets_maximum" }, { @@ -32084,7 +32084,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 175239, + "wire_schema_bytes": 175627, "tool_reference_bytes": 40307, "tool_reference_sha256": "de3bb91fc152a573a8ba16b230aa4fc0eeddf9753bf81b6f4a7ed7c0310b3e8f", "overridden_tool_count": 11, @@ -32314,7 +32314,7 @@ "save_learner_feedback" ], "ordered_tool_names_sha256": "3f29365376bb89b05f2ffe93da368202f0a56c62857541a7d9b8506e0ec8fee8", - "wire_schema_sha256": "c5f0128811b46511d9cb68f84b14167c277bfd99aace9118b648d2961b952d96", + "wire_schema_sha256": "f4db4ac8fed492665d636c80fc2bf463ee9cf16c5139db204c4384fce9144646", "target_exception_category": "synthetic_all_sets_maximum" }, { @@ -32407,7 +32407,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 175239, + "wire_schema_bytes": 175627, "tool_reference_bytes": 40434, "tool_reference_sha256": "982ac80a72ceec04be2ef36a6ebdd9a5bc4e1646ff8616b36d0bf017dedde9f7", "overridden_tool_count": 11, @@ -32637,7 +32637,7 @@ "save_learner_feedback" ], "ordered_tool_names_sha256": "3f29365376bb89b05f2ffe93da368202f0a56c62857541a7d9b8506e0ec8fee8", - "wire_schema_sha256": "c5f0128811b46511d9cb68f84b14167c277bfd99aace9118b648d2961b952d96", + "wire_schema_sha256": "f4db4ac8fed492665d636c80fc2bf463ee9cf16c5139db204c4384fce9144646", "target_exception_category": "synthetic_all_sets_maximum" }, { @@ -32729,7 +32729,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 175239, + "wire_schema_bytes": 175627, "tool_reference_bytes": 40307, "tool_reference_sha256": "de3bb91fc152a573a8ba16b230aa4fc0eeddf9753bf81b6f4a7ed7c0310b3e8f", "overridden_tool_count": 11, @@ -32959,7 +32959,7 @@ "save_learner_feedback" ], "ordered_tool_names_sha256": "3f29365376bb89b05f2ffe93da368202f0a56c62857541a7d9b8506e0ec8fee8", - "wire_schema_sha256": "c5f0128811b46511d9cb68f84b14167c277bfd99aace9118b648d2961b952d96", + "wire_schema_sha256": "f4db4ac8fed492665d636c80fc2bf463ee9cf16c5139db204c4384fce9144646", "target_exception_category": "synthetic_all_sets_maximum" }, { @@ -33053,7 +33053,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 175239, + "wire_schema_bytes": 175627, "tool_reference_bytes": 40469, "tool_reference_sha256": "7c28a9703f9524acc20bb873d0da506bd4b77375630a26e9cb4eb0c48c5f53de", "overridden_tool_count": 11, @@ -33283,7 +33283,7 @@ "save_learner_feedback" ], "ordered_tool_names_sha256": "3f29365376bb89b05f2ffe93da368202f0a56c62857541a7d9b8506e0ec8fee8", - "wire_schema_sha256": "c5f0128811b46511d9cb68f84b14167c277bfd99aace9118b648d2961b952d96", + "wire_schema_sha256": "f4db4ac8fed492665d636c80fc2bf463ee9cf16c5139db204c4384fce9144646", "target_exception_category": "synthetic_all_sets_maximum" }, { @@ -33413,7 +33413,7 @@ "maximum_provider_tool_names": [], "maximum_provider_tool_wire_bytes": 2, "maximum_provider_tool_wire_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "wire_schema_bytes": 27448, + "wire_schema_bytes": 27836, "tool_reference_bytes": 5702, "tool_reference_sha256": "8dcd4836d5397df9615fd25b6542cb7e6c5a6b200d80ccf0e1800744dee7ba44", "overridden_tool_count": 0, @@ -33449,7 +33449,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "65b4d81ef37ff23853d42ffd1095a16982d137003ef2fc40c033161edc2b40cc", - "wire_schema_sha256": "a73ce6913dd3a20ba2b5681819988144c5ef00e2e3ade6e17227dc7f28486b14", + "wire_schema_sha256": "15161c4875d729e248b27b5b33d480434a808aa4ad80bcc7d32915c85ba31a0f", "target_exception_category": "bounded_multi_domain_plan" }, { @@ -35680,7 +35680,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 11301, + "wire_schema_bytes": 11689, "tool_reference_bytes": 4981, "tool_reference_sha256": "a74c5c2bd2d65d9f5639a6aad516e33599b92edba4bf9cc24ff468efc9889c51", "overridden_tool_count": 0, @@ -35700,7 +35700,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "bbe7d7c41eafa947c0f6456ae91b140fe2fb526a765ada057940d8283bad2d3e", - "wire_schema_sha256": "e5ffabd8892dda150680b01bebb729514182e0addc159a3b4b9c0cf5b68ef443" + "wire_schema_sha256": "7c9a69d76d45adc46d4aaff25f99027000863aef5d3b242c3cf9f679c589a95c" }, { "id": "web_chat:authenticated_admin:routed:committee_event_planning", @@ -35799,7 +35799,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 17331, + "wire_schema_bytes": 17719, "tool_reference_bytes": 5272, "tool_reference_sha256": "8f6e40ff2049c4b545422207ee004ad7e0d78caba7b71253994e0ebdf73978e5", "overridden_tool_count": 0, @@ -35824,7 +35824,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "04ec5e8b84cc7c87cf26efc8cd7c37398baefb992eb687800a7c461a0f74b752", - "wire_schema_sha256": "2daa248d5815482efb2b9346512c42202b7983c70be7d3a698c0fdc504090eb4", + "wire_schema_sha256": "de01fd1789668c5375c4236e0b4c63bbedf4ad36db3d896752317bf92b9f5814", "target_exception_category": "explicit_multi_step_workflow" }, { @@ -38144,7 +38144,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 9008, + "wire_schema_bytes": 9396, "tool_reference_bytes": 4913, "tool_reference_sha256": "6c3e8581decba525396c1e53df2d6afaf7aa6e9b5bf80df401e63e1cda3bc0ea", "overridden_tool_count": 0, @@ -38162,7 +38162,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "063596a30c0f4e99cfdd77779d92a7145e459caff78c9dff3604043a9ae8c59c", - "wire_schema_sha256": "ab974565c5b82fadcde3f73292631edbe36a14535a1c668bb7dc19112b30549d" + "wire_schema_sha256": "64f071c34411113ee7b7aaa9a894156095dfb83f2b0023d762bb74fc5de0210d" }, { "id": "web_chat:authenticated_member:routed:committee_event_planning", @@ -38257,7 +38257,7 @@ ], "maximum_provider_tool_wire_bytes": 52, "maximum_provider_tool_wire_sha256": "41862a9288a9f94aa80bd76a6d113c5217ffb3b495e3b96e9ab3db014d52b275", - "wire_schema_bytes": 15038, + "wire_schema_bytes": 15426, "tool_reference_bytes": 5204, "tool_reference_sha256": "73a64178cee70c326c45c27854fa4b29f61c9dc4203c20deb8cdbde7d932d29b", "overridden_tool_count": 0, @@ -38280,7 +38280,7 @@ "list_committee_co_leaders" ], "ordered_tool_names_sha256": "3ef51ef6c23dc0bb939300d94c013762dcc27966aecdaaa1260aa3786c167cdc", - "wire_schema_sha256": "b30205b328e7ca8969df567373277d78f4729864a0c27005b635a125e754c8dc", + "wire_schema_sha256": "36883748f2d46318f0b284d2c05b1eeec5aeae054ff4087877bacf91a9abf435", "target_exception_category": "explicit_multi_step_workflow" }, { @@ -39532,7 +39532,7 @@ "maximum_slack_public_tools": -4, "maximum_slack_member_schema_bytes": -92096, "maximum_slack_admin_schema_bytes": -131761, - "maximum_slack_public_schema_bytes": 2449, + "maximum_slack_public_schema_bytes": 2837, "web_anonymous_tools": 0, "web_authenticated_admin_tools": -203 } @@ -39554,7 +39554,7 @@ "maximum_slack_public_tools": 204, "maximum_slack_member_schema_bytes": 32732, "maximum_slack_admin_schema_bytes": 35026, - "maximum_slack_public_schema_bytes": 162566, + "maximum_slack_public_schema_bytes": 162954, "web_anonymous_tools": 13, "web_authenticated_admin_tools": 35 }, @@ -39585,7 +39585,7 @@ }, "slack_bolt:admin_working_group": { "custom_tool_count": 221, - "wire_schema_bytes": 175240, + "wire_schema_bytes": 175627, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, @@ -39597,13 +39597,13 @@ }, "slack_bolt:private_channel_admin": { "custom_tool_count": 221, - "wire_schema_bytes": 175240, + "wire_schema_bytes": 175627, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, "slack_bolt:private_channel_member": { "custom_tool_count": 155, - "wire_schema_bytes": 132799, + "wire_schema_bytes": 133187, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, @@ -39621,13 +39621,13 @@ }, "slack_bolt:public_channel_admin": { "custom_tool_count": 204, - "wire_schema_bytes": 162566, + "wire_schema_bytes": 162954, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, "slack_bolt:public_channel_member": { "custom_tool_count": 150, - "wire_schema_bytes": 129526, + "wire_schema_bytes": 129914, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, @@ -39645,7 +39645,7 @@ }, "slack_bolt:system_channel_admin": { "custom_tool_count": 221, - "wire_schema_bytes": 175240, + "wire_schema_bytes": 175627, "maximum_provider_tool_count": 1, "maximum_provider_tool_wire_bytes": 52 }, @@ -39717,11 +39717,11 @@ } }, "profile_ids_sha256": "5c4b3378b3d01fbcf75aeb0a40a6b83f460060fbc3e7ec37dc8444647d804faa", - "profile_contract_sha256": "7dc421ca0ff06e1c4f52f35616b16a5be2da709f2ae25af507b61f509d0fbe4f", + "profile_contract_sha256": "3ceeacf8603f33b64c98eaa8f8988fc3fb0cb6107224f9e1e65c1b6e7096e207", "status": "within_budget" }, "profile_contract": { "profile_ids_sha256": "5c4b3378b3d01fbcf75aeb0a40a6b83f460060fbc3e7ec37dc8444647d804faa", - "profile_contract_sha256": "7dc421ca0ff06e1c4f52f35616b16a5be2da709f2ae25af507b61f509d0fbe4f" + "profile_contract_sha256": "3ceeacf8603f33b64c98eaa8f8988fc3fb0cb6107224f9e1e65c1b6e7096e207" } } diff --git a/server/src/addie/mcp/committee-leader-tools.ts b/server/src/addie/mcp/committee-leader-tools.ts index 4e16e20819c..59b365053f6 100644 --- a/server/src/addie/mcp/committee-leader-tools.ts +++ b/server/src/addie/mcp/committee-leader-tools.ts @@ -13,12 +13,19 @@ import { createLogger } from '../../logger.js'; import type { AddieTool } from '../types.js'; -import type { MemberContext } from '../member-context.js'; import { WorkingGroupDatabase } from '../../db/working-group-db.js'; import { SlackDatabase } from '../../db/slack-db.js'; import { getPool } from '../../db/client.js'; import { invalidateWebAdminStatusCache } from './admin-tools.js'; import { inviteToChannel } from '../../slack/client.js'; +import type { WorkOSUser } from '../../types.js'; +import { + mutateCommitteeLeader, + resolveCommitteeLeaderPrincipal, + type CommitteeLeaderPrincipalResolution, +} from '../../services/committee-leader-mutation.js'; +import { getAuthorizationEnforcementWorkos } from '../../auth/workos-client.js'; +import { resolveUserOrgAuthorization } from '../../utils/resolve-user-org-authorization.js'; const logger = createLogger('committee-leader-tools'); const wgDb = new WorkingGroupDatabase(); @@ -48,6 +55,10 @@ Example uses: type: 'string', description: 'Committee slug (e.g., "india-chapter", "creative-wg", "ctv-council")', }, + organization_id: { + type: 'string', + description: 'Explicitly selected WorkOS organization ID for this action', + }, user_id: { type: 'string', description: 'WorkOS user ID or Slack user ID of the person to add', @@ -57,7 +68,7 @@ Example uses: description: 'Email address of the person to add (optional, helps identify them)', }, }, - required: ['committee_slug', 'user_id'], + required: ['committee_slug', 'organization_id', 'user_id'], }, }, { @@ -76,12 +87,16 @@ You cannot remove yourself as a leader (contact admin for that).`, type: 'string', description: 'Committee slug (e.g., "india-chapter", "creative-wg", "ctv-council")', }, + organization_id: { + type: 'string', + description: 'Explicitly selected WorkOS organization ID for this action', + }, user_id: { type: 'string', description: 'WorkOS user ID or Slack user ID of the person to remove', }, }, - required: ['committee_slug', 'user_id'], + required: ['committee_slug', 'organization_id', 'user_id'], }, }, { @@ -97,8 +112,12 @@ Works for working groups, councils, chapters, and industry gatherings.`, type: 'string', description: 'Committee slug (e.g., "india-chapter", "creative-wg", "ctv-council")', }, + organization_id: { + type: 'string', + description: 'Explicitly selected WorkOS organization ID for this request', + }, }, - required: ['committee_slug'], + required: ['committee_slug', 'organization_id'], }, }, ]; @@ -131,6 +150,41 @@ export async function isCommitteeLeader(slackUserId: string): Promise { } } +type CommitteeLeaderImmutablePrincipal = Pick< + WorkOSUser, + 'id' | 'authWorkosUserId' | 'authorizationSnapshot' +>; + +export type CommitteeLeaderHandlerOptions = + | { + /** Web callers must supply the immutable authenticated request principal. */ + surface: 'web'; + principal: CommitteeLeaderImmutablePrincipal; + } + | { + /** Slack binds its signed actor id to one live mapping, then revalidates it in-transaction. */ + surface: 'slack'; + principal?: CommitteeLeaderImmutablePrincipal; + }; + +function isLiveSlackCredentialMapping(mapping: { + workos_user_id?: string | null; + mapping_status?: string | null; + slack_is_deleted?: boolean | null; + slack_is_bot?: boolean | null; +} | null): mapping is { + workos_user_id: string; + mapping_status: 'mapped'; + slack_is_deleted: false; + slack_is_bot: false; +} { + return mapping?.mapping_status === 'mapped' + && mapping.slack_is_deleted === false + && mapping.slack_is_bot === false + && typeof mapping.workos_user_id === 'string' + && mapping.workos_user_id.length > 0; +} + /** * Get the committees a user leads */ @@ -168,40 +222,99 @@ function formatCommitteeType(type: string): string { * before allowing them to modify leadership. */ export function createCommitteeLeaderToolHandlers( - memberContext?: MemberContext | null, - slackUserId?: string + _memberContext: unknown | undefined, + slackUserId: string | undefined, + options: CommitteeLeaderHandlerOptions, ): Map) => Promise> { const handlers = new Map) => Promise>(); - /** - * Get the current user's WorkOS ID from context or Slack mapping - */ - const getCurrentUserWorkosId = async (): Promise => { - // Try to get from member context first - if (memberContext?.workos_user?.workos_user_id) { - return memberContext.workos_user.workos_user_id; + /** Resolve exact authentication provenance; MemberContext never grants authority. */ + const resolvePrincipal = async (): Promise => { + const requestSnapshot = options.principal?.authorizationSnapshot; + if (requestSnapshot) { + return { status: 'resolved', snapshot: requestSnapshot }; } - // Fall back to Slack mapping - if (slackUserId) { - const mapping = await slackDb.getBySlackUserId(slackUserId); - return mapping?.workos_user_id || null; + const requestCredentialId = options.principal + ? options.principal.authWorkosUserId ?? options.principal.id + : null; + if (requestCredentialId) { + return resolveCommitteeLeaderPrincipal(requestCredentialId); } - return null; + if (options.surface !== 'slack' || !slackUserId) { + return { status: 'forbidden', reason: 'principal_missing' }; + } + try { + const mapping = await slackDb.getBySlackUserId(slackUserId); + if (!isLiveSlackCredentialMapping(mapping)) { + return { status: 'forbidden', reason: 'credential_revoked' }; + } + return resolveCommitteeLeaderPrincipal(mapping.workos_user_id); + } catch (error) { + logger.warn({ err: error }, 'Slack credential mapping unavailable for committee leader tool'); + return { status: 'unavailable', source: 'database' }; + } }; /** * Check if the current user leads the specified committee */ - const checkUserLeadsCommittee = async (committeeSlug: string): Promise<{ allowed: boolean; error?: string; committee?: { id: string; name: string; committee_type: string; slack_channel_id?: string | null } }> => { - const workosUserId = await getCurrentUserWorkosId(); - if (!workosUserId) { + const checkUserLeadsCommittee = async (committeeSlug: string, organizationId: string): Promise<{ allowed: boolean; unavailable?: boolean; error?: string; committee?: { id: string; name: string; committee_type: string; slack_channel_id?: string | null } }> => { + const principal = await resolvePrincipal(); + if (principal.status === 'unavailable') { + return { + allowed: false, + unavailable: true, + error: 'Committee authorization is temporarily unavailable. Please try again.', + }; + } + if (principal.status === 'forbidden') { return { allowed: false, error: 'You need to link your Slack account to your AgenticAdvertising.org account to manage committee leadership.', }; } + if (principal.snapshot.selectedOrganizationId + && principal.snapshot.selectedOrganizationId !== organizationId) { + return { + allowed: false, + error: 'Select the same organization for the authenticated session and this committee request.', + }; + } + let orgAuthorization; + try { + orgAuthorization = await resolveUserOrgAuthorization( + getAuthorizationEnforcementWorkos(), + { + id: principal.snapshot.canonicalUserId, + authWorkosUserId: principal.snapshot.authenticatedUserId, + authorizationSnapshot: principal.snapshot, + }, + organizationId, + ); + } catch (error) { + logger.warn({ err: error }, 'Committee leader organization authorization unavailable'); + return { + allowed: false, + unavailable: true, + error: 'Committee authorization is temporarily unavailable. Please try again.', + }; + } + if (orgAuthorization.status === 'unavailable' + || (orgAuthorization.status === 'authorized' && !orgAuthorization.complete)) { + return { + allowed: false, + unavailable: true, + error: 'Committee authorization is temporarily unavailable. Please try again.', + }; + } + if (orgAuthorization.status !== 'authorized') { + return { + allowed: false, + error: 'This authenticated credential is not authorized for the selected organization.', + }; + } // Get the committee const committee = await wgDb.getWorkingGroupBySlug(committeeSlug); @@ -213,6 +326,7 @@ export function createCommitteeLeaderToolHandlers( } // Check if user is a leader + const workosUserId = principal.snapshot.authenticatedUserId; const isLeader = await wgDb.isLeader(committee.id, workosUserId); if (!isLeader) { // Get committees they do lead to give helpful context @@ -238,7 +352,8 @@ export function createCommitteeLeaderToolHandlers( // ============================================ handlers.set('add_committee_co_leader', async (input) => { const committeeSlug = (input.committee_slug as string)?.trim(); - let userId = (input.user_id as string)?.trim(); + const organizationId = (input.organization_id as string)?.trim(); + const userId = (input.user_id as string)?.trim(); const userEmail = input.user_email as string | undefined; if (!committeeSlug) { @@ -248,60 +363,65 @@ export function createCommitteeLeaderToolHandlers( if (!userId) { return '❌ Please provide a user_id (WorkOS user ID or Slack user ID).'; } - - // Check permission - const permCheck = await checkUserLeadsCommittee(committeeSlug); - if (!permCheck.allowed) { - return `⚠️ ${permCheck.error}`; + if (!organizationId) { + return '⚠️ Please explicitly select an organization_id before changing committee leadership.'; } - const committee = permCheck.committee!; if (committeeSlug === 'aao-admin') { return '⚠️ AAO site-admin membership must be changed through the dedicated audited admin workflow.'; } try { - // Resolve to canonical ID for consistent comparison - // The DB methods also resolve, but we need the canonical ID for the "already a leader" check - const canonicalUserId = await wgDb.resolveToCanonicalUserId(userId); - - // Check if already a leader - const leaders = await wgDb.getLeaders(committee.id); - if (leaders.some((l) => l.canonical_user_id === canonicalUserId)) { - return `ℹ️ This person is already a leader of ${committee.name}.`; + const principal = await resolvePrincipal(); + if (principal.status === 'unavailable') { + return '❌ Committee authorization is temporarily unavailable. No changes were made.'; } - - // Add as leader (DB method also resolves IDs, but we pass canonical for consistency) - await wgDb.addLeader(committee.id, canonicalUserId); - - // Invalidate cache after adding as leader (leadership affects permissions) - invalidateWebAdminStatusCache(canonicalUserId); - - // Also ensure they're a member - const memberships = await wgDb.getMembershipsByWorkingGroup(committee.id); - if (!memberships.some(m => m.workos_user_id === canonicalUserId)) { - await wgDb.addMembership({ - working_group_id: committee.id, - workos_user_id: canonicalUserId, - user_email: userEmail, - }); + if (principal.status === 'forbidden') { + return '⚠️ This authenticated credential is not authorized to change committee leadership.'; + } + const result = await mutateCommitteeLeader({ + action: 'add', + principal: principal.snapshot, + selectedOrganizationId: organizationId, + committeeSlug, + targetUserId: userId, + targetEmail: userEmail, + surface: options.surface, + slackActorUserId: slackUserId, + }); + if (result.status === 'unavailable') { + return '❌ Committee authorization is temporarily unavailable. No changes were made.'; + } + if (result.status === 'forbidden') { + if (result.reason === 'organization_required' || result.reason === 'organization_mismatch') { + return '⚠️ Select the same organization for the authenticated session and this committee action.'; + } + if (result.reason === 'committee_not_found') { + return `⚠️ Committee "${committeeSlug}" not found. Check the slug and try again.`; + } + return '⚠️ This authenticated credential is not authorized to change leadership for that committee.'; } + if (result.status === 'unchanged') { + return `ℹ️ This person is already a leader of ${result.committeeName}.`; + } + + invalidateWebAdminStatusCache(result.targetWorkosUserId); // Auto-invite to the group's Slack channel (fire-and-forget) - if (committee.slack_channel_id) { - slackDb.getByWorkosUserId(canonicalUserId).then(mapping => { + if (result.slackChannelId) { + slackDb.getByWorkosUserId(result.targetWorkosUserId).then(mapping => { if (mapping?.slack_user_id) { - return inviteToChannel(committee.slack_channel_id!, [mapping.slack_user_id]); + return inviteToChannel(result.slackChannelId!, [mapping.slack_user_id]); } }).catch(err => { - logger.error({ err, userId: canonicalUserId, channelId: committee.slack_channel_id }, 'Failed to auto-invite co-leader to Slack channel'); + logger.error({ err, userId: result.targetWorkosUserId, channelId: result.slackChannelId }, 'Failed to auto-invite co-leader to Slack channel'); }); } - logger.info({ committeeSlug, committeeName: committee.name, userId: canonicalUserId, userEmail, addedBy: slackUserId }, 'Added committee co-leader'); + logger.info({ committeeSlug, committeeName: result.committeeName, userId: result.targetWorkosUserId, userEmail, addedBy: slackUserId }, 'Added committee co-leader'); const emailInfo = userEmail ? ` (${userEmail})` : ''; - const typeLabel = formatCommitteeType(committee.committee_type); - return `✅ Successfully added ${userId}${emailInfo} as a co-leader of **${committee.name}**. + const typeLabel = formatCommitteeType(result.committeeType); + return `✅ Successfully added ${userId}${emailInfo} as a co-leader of **${result.committeeName}**. They now have management access to: - Create and manage ${typeLabel} events @@ -320,7 +440,8 @@ Management page: https://agenticadvertising.org/working-groups/${committeeSlug}/ // ============================================ handlers.set('remove_committee_co_leader', async (input) => { const committeeSlug = (input.committee_slug as string)?.trim(); - let userId = (input.user_id as string)?.trim(); + const organizationId = (input.organization_id as string)?.trim(); + const userId = (input.user_id as string)?.trim(); if (!committeeSlug) { return '❌ Please provide a committee_slug (e.g., "india-chapter", "creative-wg").'; @@ -329,38 +450,48 @@ Management page: https://agenticadvertising.org/working-groups/${committeeSlug}/ if (!userId) { return '❌ Please provide a user_id (WorkOS user ID or Slack user ID).'; } - - // Check permission - const permCheck = await checkUserLeadsCommittee(committeeSlug); - if (!permCheck.allowed) { - return `⚠️ ${permCheck.error}`; + if (!organizationId) { + return '⚠️ Please explicitly select an organization_id before changing committee leadership.'; } - const committee = permCheck.committee!; try { - // Resolve to canonical ID for consistent comparison - const canonicalUserId = await wgDb.resolveToCanonicalUserId(userId); - - // Get current user's WorkOS ID - const currentUserWorkosId = await getCurrentUserWorkosId(); - - // Prevent removing yourself - compare canonical IDs to prevent bypass - if (canonicalUserId === currentUserWorkosId) { - return `⚠️ You cannot remove yourself as a leader. If you want to step down from leading ${committee.name}, please contact an admin.`; + const principal = await resolvePrincipal(); + if (principal.status === 'unavailable') { + return '❌ Committee authorization is temporarily unavailable. No changes were made.'; } - - // Check if they are a leader - const leaders = await wgDb.getLeaders(committee.id); - if (!leaders.some((l) => l.canonical_user_id === canonicalUserId)) { - return `ℹ️ This person is not a leader of ${committee.name}.`; + if (principal.status === 'forbidden') { + return '⚠️ This authenticated credential is not authorized to change committee leadership.'; + } + const result = await mutateCommitteeLeader({ + action: 'remove', + principal: principal.snapshot, + selectedOrganizationId: organizationId, + committeeSlug, + targetUserId: userId, + surface: options.surface, + slackActorUserId: slackUserId, + }); + if (result.status === 'unavailable') { + return '❌ Committee authorization is temporarily unavailable. No changes were made.'; + } + if (result.status === 'forbidden') { + if (result.reason === 'self_removal_forbidden') { + return '⚠️ You cannot remove your own linked identity as a leader. Please contact an admin.'; + } + if (result.reason === 'organization_required' || result.reason === 'organization_mismatch') { + return '⚠️ Select the same organization for the authenticated session and this committee action.'; + } + return '⚠️ This authenticated credential is not authorized to change leadership for that committee.'; + } + if (result.status === 'unchanged') { + return `ℹ️ This person is not a leader of ${result.committeeName}.`; } - await wgDb.removeLeader(committee.id, canonicalUserId); - invalidateWebAdminStatusCache(canonicalUserId); + invalidateWebAdminStatusCache(result.targetWorkosUserId); - logger.info({ committeeSlug, committeeName: committee.name, userId: canonicalUserId, removedBy: slackUserId }, 'Removed committee co-leader'); + logger.info({ committeeSlug, committeeName: result.committeeName, userId: result.targetWorkosUserId, removedBy: slackUserId }, 'Removed committee co-leader'); - return `✅ Successfully removed as a leader of **${committee.name}**. + return `✅ Successfully removed as a leader of **${result.committeeName}**. They are still a member but no longer have management access.`; } catch (error) { @@ -374,13 +505,17 @@ They are still a member but no longer have management access.`; // ============================================ handlers.set('list_committee_co_leaders', async (input) => { const committeeSlug = (input.committee_slug as string)?.trim(); + const organizationId = (input.organization_id as string)?.trim(); if (!committeeSlug) { return '❌ Please provide a committee_slug (e.g., "india-chapter", "creative-wg").'; } + if (!organizationId) { + return '⚠️ Please explicitly select an organization_id before listing committee leadership.'; + } // Check permission - const permCheck = await checkUserLeadsCommittee(committeeSlug); + const permCheck = await checkUserLeadsCommittee(committeeSlug, organizationId); if (!permCheck.allowed) { return `⚠️ ${permCheck.error}`; } diff --git a/server/src/routes/addie-chat.ts b/server/src/routes/addie-chat.ts index 3819a41af6a..990ccadb811 100644 --- a/server/src/routes/addie-chat.ts +++ b/server/src/routes/addie-chat.ts @@ -15,6 +15,7 @@ import cors from "cors"; import { createLogger } from "../logger.js"; import { CachedPostgresStore } from "../middleware/pg-rate-limit-store.js"; import { optionalAuth } from "../middleware/auth.js"; +import type { WorkOSUser } from "../types.js"; import { serveHtmlWithConfig } from "../utils/html-config.js"; import { AddieClaudeClient, type AddieResponse, type RequestTools } from "../addie/claude-client.js"; import { @@ -791,6 +792,7 @@ export async function prepareRequestWithMemberTools( isAuthenticated: boolean, threadId?: string, selectedOrganizationId?: string | null, + authorizationPrincipal?: WorkOSUser, ): Promise { const messageToProcess = sanitizedInput; let memberContext: MemberContext | null = null; @@ -1017,10 +1019,19 @@ export async function prepareRequestWithMemberTools( combinedHandlers.set(name, handler); } - // Committee leader tools (uses memberContext.workos_user for identity, Slack ID for fallback) - allTools.push(...COMMITTEE_LEADER_TOOLS); - for (const [name, handler] of createCommitteeLeaderToolHandlers(memberContext, linkedSlackUserId)) { - combinedHandlers.set(name, handler); + // Committee authority is bound to the exact immutable request principal; + // canonical member context remains attribution/person state only. A + // caller without immutable authentication provenance gets no mutation + // definition or handler at all. + if (authorizationPrincipal) { + allTools.push(...COMMITTEE_LEADER_TOOLS); + for (const [name, handler] of createCommitteeLeaderToolHandlers( + memberContext, + linkedSlackUserId, + { principal: authorizationPrincipal, surface: 'web' }, + )) { + combinedHandlers.set(name, handler); + } } } @@ -1335,7 +1346,8 @@ export function createAddieChatRouter(options?: { externalId, isAuth, thread.thread_id, - typeof organization_id === 'string' ? organization_id : null + typeof organization_id === 'string' ? organization_id : null, + req.user, ); const tieredAccess = buildTieredAccess( memberTools, @@ -1922,7 +1934,8 @@ export function createAddieChatRouter(options?: { externalId, isAuth, thread.thread_id, - typeof organization_id === 'string' ? organization_id : null + typeof organization_id === 'string' ? organization_id : null, + req.user, ); const tieredAccess = buildTieredAccess(memberTools, isAuth, hasThreadCertCtx); const activeCertificationKind = classifyActiveCertificationProgress( diff --git a/server/src/routes/tavus.ts b/server/src/routes/tavus.ts index 8f60de8cbb6..119ed8346b2 100644 --- a/server/src/routes/tavus.ts +++ b/server/src/routes/tavus.ts @@ -71,10 +71,6 @@ import { COLLABORATION_TOOLS, createCollaborationToolHandlers, } from "../addie/mcp/collaboration-tools.js"; -import { - COMMITTEE_LEADER_TOOLS, - createCommitteeLeaderToolHandlers, -} from "../addie/mcp/committee-leader-tools.js"; import { MOLTBOOK_TOOLS, createMoltbookToolHandlers, @@ -200,9 +196,16 @@ function validateLlmSecret(req: Request): boolean { * Build user-scoped tools for a voice call, matching the web chat tool set. * This gives voice Addie the same capabilities as chat Addie. */ -async function buildVoiceRequestTools( +export interface TavusVoiceRequestToolDependencies { + getMemberContext?: (userId: string) => Promise; + isAdmin?: (userId: string) => Promise; + getCommitteesLedByUser?: (userId: string) => Promise>; +} + +export async function buildVoiceRequestTools( userId: string, threadId: string, + dependencies: TavusVoiceRequestToolDependencies = {}, ): Promise<{ requestTools: RequestTools; requestContext: string; @@ -211,7 +214,7 @@ async function buildVoiceRequestTools( }> { let memberContext: MemberContext | null = null; try { - memberContext = await getWebMemberContext(userId); + memberContext = await (dependencies.getMemberContext ?? getWebMemberContext)(userId); } catch (error) { logger.warn({ error, userId }, "Tavus: Failed to get member context"); } @@ -259,10 +262,11 @@ async function buildVoiceRequestTools( } // Permission-gated tools - const workingGroupDb = new WorkingGroupDatabase(); const [userIsAdmin, ledGroups] = await Promise.all([ - isWebUserAAOAdmin(userId), - workingGroupDb.getCommitteesLedByUser(userId), + (dependencies.isAdmin ?? isWebUserAAOAdmin)(userId), + dependencies.getCommitteesLedByUser + ? dependencies.getCommitteesLedByUser(userId) + : new WorkingGroupDatabase().getCommitteesLedByUser(userId), ]); // Event tools: readonly for all users, admin tools for admins only @@ -297,10 +301,10 @@ async function buildVoiceRequestTools( combinedHandlers.set(name, handler); } - allTools.push(...COMMITTEE_LEADER_TOOLS); - for (const [name, handler] of createCommitteeLeaderToolHandlers(memberContext, linkedSlackUserId)) { - combinedHandlers.set(name, handler); - } + // #7450 continuation: Tavus currently persists only the canonical thread + // owner and cannot prove the immutable authenticated WorkOS credential for + // this voice turn. Do not register committee mutation definitions or + // handlers until the exact voice principal is carried end-to-end. if (process.env.MOLTBOOK_API_KEY) { allTools.push(...MOLTBOOK_TOOLS); diff --git a/server/src/services/committee-leader-mutation.ts b/server/src/services/committee-leader-mutation.ts new file mode 100644 index 00000000000..ee086c6ae6f --- /dev/null +++ b/server/src/services/committee-leader-mutation.ts @@ -0,0 +1,839 @@ +import { isDeepStrictEqual } from 'node:util'; +import type { PoolClient } from 'pg'; +import type { WorkOS } from '@workos-inc/node'; +import { getAuthorizationEnforcementWorkos } from '../auth/workos-client.js'; +import { getPool } from '../db/client.js'; +import { + loadAuthorizationSnapshot, + sameAuthorizationIdentity, + sameAuthorizationSnapshot, + type AuthorizationSnapshot, +} from '../db/user-authorization-snapshot-db.js'; +import { CommunityDatabase } from '../db/community-db.js'; +import { computeJourneyStage } from '../addie/services/journey-computation.js'; +import { createLogger } from '../logger.js'; + +const logger = createLogger('committee-leader-mutation'); + +type WorkOSAuthorizationClient = Pick; + +export type CommitteeLeaderMutationDenialReason = + | 'principal_missing' + | 'organization_required' + | 'organization_mismatch' + | 'credential_revoked' + | 'authorization_changed' + | 'organization_forbidden' + | 'committee_not_found' + | 'committee_forbidden' + | 'not_committee_leader' + | 'target_invalid' + | 'self_removal_forbidden'; + +export type CommitteeLeaderMutationResult = + | { + status: 'mutated'; + action: 'added' | 'removed'; + committeeId: string; + committeeName: string; + committeeType: string; + slackChannelId: string | null; + targetWorkosUserId: string; + } + | { + status: 'unchanged'; + reason: 'already_leader' | 'not_leader'; + committeeId: string; + committeeName: string; + committeeType: string; + slackChannelId: string | null; + targetWorkosUserId: string; + } + | { status: 'forbidden'; reason: CommitteeLeaderMutationDenialReason } + | { status: 'unavailable'; source: 'database' | 'workos' }; + +export type CommitteeLeaderPrincipalResolution = + | { status: 'resolved'; snapshot: AuthorizationSnapshot } + | { status: 'forbidden'; reason: 'principal_missing' | 'credential_revoked' } + | { status: 'unavailable'; source: 'database' }; + +export interface CommitteeLeaderMutationInput { + action: 'add' | 'remove'; + principal: AuthorizationSnapshot; + selectedOrganizationId: string; + committeeSlug: string; + targetUserId: string; + targetEmail?: string; + surface: 'web' | 'slack'; + slackActorUserId?: string; +} + +interface CommitteeLeaderMutationDependencies { + getWorkos?: () => WorkOSAuthorizationClient; + /** Integration-test barrier. Production callers never supply hooks. */ + afterProviderAuthorization?: () => Promise; + /** Integration seam for post-commit business side effects. */ + afterCommit?: (event: CommitteeLeaderMutationCommittedEvent) => void; +} + +interface CommitteeLeaderMutationCommittedEvent { + action: 'added' | 'removed'; + committeeId: string; + organizationId: string; + targetWorkosUserId: string; +} + +interface LockedActorRow { + authenticated_user_id: string; + canonical_user_id: string; + identity_id: string; + authorization_epoch: string; +} + +interface LockedCommitteeRow { + id: string; + name: string; + committee_type: string; + slack_channel_id: string | null; + slug: string; +} + +interface LockedGrantRow { + id: string; + organization_id: string; + role: 'owner' | 'admin' | 'member'; + effective_from: string; + effective_until: string | null; +} + +interface LockedLeadershipRow { + user_id: string; +} + +interface LockedSlackMappingRow { + slack_user_id: string; + workos_user_id: string | null; + mapping_status: string; + slack_is_deleted: boolean | null; + slack_is_bot: boolean | null; +} + +interface LockedLeadershipState { + leaderUserIds: string[]; + slackMappings: Map; +} + +interface ResolvedTargetCredential { + kind: 'workos' | 'slack'; + workosUserId: string; + slackUserId: string | null; +} + +interface InsertedAuditRow { + id: string; + workos_organization_id: string; + workos_user_id: string; + action: string; + resource_type: string; + resource_id: string; + details: Record; +} + +function normalizedRequiredId(value: string): string | null { + const normalized = value.trim(); + return normalized.length > 0 && normalized === value ? normalized : null; +} + +/** Resolve a fresh immutable principal without accepting canonical MemberContext identity. */ +export async function resolveCommitteeLeaderPrincipal( + authenticatedWorkosUserId: string | null | undefined, +): Promise { + if (!authenticatedWorkosUserId?.trim()) { + return { status: 'forbidden', reason: 'principal_missing' }; + } + try { + const snapshot = await loadAuthorizationSnapshot(authenticatedWorkosUserId.trim(), null); + return snapshot + ? { status: 'resolved', snapshot } + : { status: 'forbidden', reason: 'credential_revoked' }; + } catch (error) { + logger.warn({ err: error }, 'Committee leader principal snapshot unavailable'); + return { status: 'unavailable', source: 'database' }; + } +} + +async function lockActorState( + client: PoolClient, + authenticatedWorkosUserId: string, +): Promise { + const result = await client.query( + `SELECT 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 + FROM users credential + JOIN identity_workos_users binding + ON binding.workos_user_id = credential.workos_user_id + 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 + WHERE credential.workos_user_id = $1 + FOR KEY SHARE OF credential + FOR SHARE OF binding, primary_binding`, + [authenticatedWorkosUserId], + ); + return result.rows[0] ?? null; +} + +async function lockAuthorizationEpoch( + client: PoolClient, + authenticatedWorkosUserId: string, +): Promise { + await client.query( + `SELECT epoch + FROM authorization_epochs + WHERE workos_user_id = $1 + FOR UPDATE`, + [authenticatedWorkosUserId], + ); +} + +function actorMatchesSnapshot(row: LockedActorRow, snapshot: AuthorizationSnapshot): boolean { + return row.authenticated_user_id === snapshot.authenticatedUserId + && row.canonical_user_id === snapshot.canonicalUserId + && row.identity_id === snapshot.identityId + && row.authorization_epoch === snapshot.authorizationEpoch; +} + +function liveMappedWorkosUserId(mapping: LockedSlackMappingRow | undefined): string | null { + return mapping?.mapping_status === 'mapped' + && mapping.slack_is_deleted === false + && mapping.slack_is_bot === false + && typeof mapping.workos_user_id === 'string' + && mapping.workos_user_id.length > 0 + ? mapping.workos_user_id + : null; +} + +async function getActiveCredentialGrant( + client: PoolClient, + authenticatedWorkosUserId: string, + selectedOrganizationId: string, +): Promise { + const result = await client.query( + `SELECT id, workos_organization_id AS organization_id, role, + to_char(effective_from AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS effective_from, + to_char(effective_until AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS effective_until + FROM organization_credential_grants + WHERE workos_user_id = $1 + AND workos_organization_id = $2 + AND revoked_at IS NULL + AND effective_from <= clock_timestamp() + AND (effective_until IS NULL OR effective_until > clock_timestamp()) + FOR UPDATE`, + [authenticatedWorkosUserId, selectedOrganizationId], + ); + return result.rows[0] ?? null; +} + +function grantMatchesSnapshot( + grant: LockedGrantRow | null, + snapshot: AuthorizationSnapshot, +): boolean { + const expected = snapshot.credentialGrant; + if (!grant || !expected) return grant === null && expected === null; + return grant.id === expected.id + && grant.organization_id === expected.organizationId + && grant.role === expected.role + && grant.effective_from === expected.effectiveFrom + && grant.effective_until === expected.effectiveUntil; +} + +async function lockCommitteeLeadershipState( + client: PoolClient, + committeeId: string, + extraSlackUserIds: string[], +): Promise { + const leaders = await client.query( + `SELECT user_id + FROM working_group_leaders + WHERE working_group_id = $1 + ORDER BY user_id + FOR UPDATE`, + [committeeId], + ); + const leaderUserIds = leaders.rows.map((row) => row.user_id); + const aliases = [...new Set([...leaderUserIds, ...extraSlackUserIds].filter(Boolean))].sort(); + if (aliases.length === 0) { + return { leaderUserIds, slackMappings: new Map() }; + } + const mappings = await client.query( + `SELECT slack_user_id, workos_user_id, mapping_status, + slack_is_deleted, slack_is_bot + FROM slack_user_mappings + WHERE slack_user_id = ANY($1) + ORDER BY slack_user_id + FOR SHARE`, + [aliases], + ); + return { + leaderUserIds, + slackMappings: new Map(mappings.rows.map((row) => [row.slack_user_id, row])), + }; +} + +function hasExactCommitteeLeadership( + state: LockedLeadershipState, + authenticatedWorkosUserId: string, +): boolean { + return state.leaderUserIds.some((leaderUserId) => + leaderUserId === authenticatedWorkosUserId + || liveMappedWorkosUserId(state.slackMappings.get(leaderUserId)) + === authenticatedWorkosUserId + ); +} + +async function resolveTargetCredential( + client: PoolClient, + targetUserId: string, +): Promise { + const result = await client.query<{ + kind: 'workos' | 'slack'; + workos_user_id: string; + slack_user_id: string | null; + }>( + `SELECT candidate.kind, candidate.workos_user_id, candidate.slack_user_id + FROM ( + SELECT 'workos'::text AS kind, credential.workos_user_id, + NULL::text AS slack_user_id, 0 AS priority + FROM users credential + JOIN identity_workos_users binding + ON binding.workos_user_id = credential.workos_user_id + WHERE credential.workos_user_id = $1 + UNION ALL + SELECT 'slack'::text AS kind, credential.workos_user_id, + mapping.slack_user_id, 1 AS priority + FROM slack_user_mappings mapping + JOIN users credential + ON credential.workos_user_id = mapping.workos_user_id + JOIN identity_workos_users binding + ON binding.workos_user_id = credential.workos_user_id + WHERE mapping.slack_user_id = $1 + AND mapping.mapping_status = 'mapped' + AND mapping.slack_is_deleted = FALSE + AND mapping.slack_is_bot = FALSE + ) candidate + ORDER BY candidate.priority + LIMIT 1`, + [targetUserId], + ); + const row = result.rows[0]; + return row + ? { kind: row.kind, workosUserId: row.workos_user_id, slackUserId: row.slack_user_id } + : null; +} + +async function lockExactTargetCredential( + client: PoolClient, + workosUserId: string, +): Promise { + const result = await client.query( + `SELECT 1 + FROM users credential + JOIN identity_workos_users binding + ON binding.workos_user_id = credential.workos_user_id + WHERE credential.workos_user_id = $1 + FOR KEY SHARE OF credential + FOR SHARE OF binding`, + [workosUserId], + ); + return result.rowCount === 1; +} + +async function bumpTargetAuthorizationEpochExactlyOnce( + client: PoolClient, + workosUserId: string, +): Promise { + const result = await client.query<{ epoch: string }>( + `INSERT INTO authorization_epochs (workos_user_id, epoch) + SELECT workos_user_id, 1 + FROM users + WHERE workos_user_id = $1 + ON CONFLICT (workos_user_id) DO UPDATE + SET epoch = authorization_epochs.epoch + 1, updated_at = NOW() + RETURNING epoch::text`, + [workosUserId], + ); + if (result.rowCount !== 1 || !result.rows[0]?.epoch) { + throw new Error('Target credential epoch bump did not affect exactly one live credential'); + } + return result.rows[0].epoch; +} + +async function targetBelongsToActorIdentity( + client: PoolClient, + targetWorkosUserId: string, + actorIdentityId: string, +): Promise { + const result = await client.query( + `SELECT 1 + FROM identity_workos_users + WHERE workos_user_id = $1 + AND identity_id = $2 + FOR SHARE`, + [targetWorkosUserId, actorIdentityId], + ); + return result.rowCount === 1; +} + +async function hasActiveWorkOSMembership( + workos: WorkOSAuthorizationClient, + authenticatedWorkosUserId: string, + selectedOrganizationId: string, +): Promise { + const memberships = await workos.userManagement.listOrganizationMemberships({ + userId: authenticatedWorkosUserId, + organizationId: selectedOrganizationId, + }); + return memberships.data.some((membership) => + membership.userId === authenticatedWorkosUserId + && membership.organizationId === selectedOrganizationId + && membership.status === 'active' + ); +} + +function scheduleLeadershipSideEffects(event: CommitteeLeaderMutationCommittedEvent): void { + computeJourneyStage( + event.organizationId, + 'leadership_change', + `working_group:${event.committeeId}`, + ).catch((error) => { + logger.error({ err: error, workingGroupId: event.committeeId }, 'Journey stage computation failed'); + }); + + if (event.action === 'added') { + new CommunityDatabase().awardPoints( + event.targetWorkosUserId, + 'wg_leadership', + 30, + event.committeeId, + 'working_group', + ).catch((error) => { + logger.error({ err: error, workingGroupId: event.committeeId }, 'Failed to award leadership points'); + }); + } +} + +/** + * Authorize and commit one co-leader mutation. The provider decision is made + * while deterministic credential/committee/target locks are held, then the + * exact binding, epoch, grant, and leadership rows are re-read immediately + * before the write. Any provider or database outage fails closed. + */ +export async function mutateCommitteeLeader( + input: CommitteeLeaderMutationInput, + dependencies: CommitteeLeaderMutationDependencies = {}, +): Promise { + const organizationId = normalizedRequiredId(input.selectedOrganizationId); + if (!organizationId) return { status: 'forbidden', reason: 'organization_required' }; + if (input.principal.selectedOrganizationId + && input.principal.selectedOrganizationId !== organizationId) { + return { status: 'forbidden', reason: 'organization_mismatch' }; + } + + // Rebind a request snapshot that began without an organization only when the + // tool input explicitly names one. Identity/epoch changes are still denied. + let admissionSnapshot: AuthorizationSnapshot; + try { + const current = await loadAuthorizationSnapshot(input.principal.authenticatedUserId, organizationId); + if (!current) return { status: 'forbidden', reason: 'credential_revoked' }; + const unchanged = input.principal.selectedOrganizationId === null + ? sameAuthorizationIdentity(input.principal, current) + : sameAuthorizationSnapshot(input.principal, current); + if (!unchanged) { + return { status: 'forbidden', reason: 'authorization_changed' }; + } + admissionSnapshot = current; + } catch (error) { + logger.warn({ err: error }, 'Committee leader pre-mutation snapshot unavailable'); + return { status: 'unavailable', source: 'database' }; + } + + let workos: WorkOSAuthorizationClient; + try { + workos = (dependencies.getWorkos ?? getAuthorizationEnforcementWorkos)(); + } catch (error) { + logger.warn({ err: error }, 'Committee leader WorkOS client unavailable'); + return { status: 'unavailable', source: 'workos' }; + } + + let client: PoolClient; + try { + client = await getPool().connect(); + } catch (error) { + logger.warn({ err: error }, 'Committee leader database checkout unavailable'); + return { status: 'unavailable', source: 'database' }; + } + + try { + await client.query('BEGIN'); + await client.query(`SET LOCAL lock_timeout = '2000ms'`); + await client.query(`SET LOCAL statement_timeout = '8000ms'`); + + const targetCredential = await resolveTargetCredential(client, input.targetUserId); + if (!targetCredential) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'target_invalid' }; + } + const targetWorkosUserId = targetCredential.workosUserId; + + // Committee mutations are the only participants in this advisory + // protocol. Lock actor + target credentials in lexical order before any + // row locks so cross-target mutations cannot each consume epoch 0 and + // then bump the other credential. Revocation writers do not acquire this + // advisory lock; their concrete binding/grant/leader row locks still win + // or serialize without the late-lock inversion found in the first review. + const credentialLockKeys = [ + `committee-credential:${input.principal.authenticatedUserId}`, + `committee-credential:${targetWorkosUserId}`, + ].sort(); + await client.query( + `SELECT pg_advisory_xact_lock(hashtextextended(lock_key, 0)) + FROM unnest($1::text[]) AS lock_key + ORDER BY lock_key`, + [credentialLockKeys], + ); + + const actor = await lockActorState(client, input.principal.authenticatedUserId); + if (!actor) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'credential_revoked' }; + } + if (!actorMatchesSnapshot(actor, input.principal)) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'authorization_changed' }; + } + + const committeeResult = await client.query( + `SELECT id, name, committee_type, slack_channel_id, slug + FROM working_groups + WHERE slug = $1 + FOR UPDATE`, + [input.committeeSlug], + ); + const committee = committeeResult.rows[0]; + if (!committee) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'committee_not_found' }; + } + if (committee.slug === 'aao-admin') { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'committee_forbidden' }; + } + + const credentialGrant = await getActiveCredentialGrant( + client, + actor.authenticated_user_id, + organizationId, + ); + const leadershipState = await lockCommitteeLeadershipState( + client, + committee.id, + [input.targetUserId, input.slackActorUserId ?? ''], + ); + const lockedTargetWorkosUserId = targetCredential.kind === 'slack' + ? liveMappedWorkosUserId(leadershipState.slackMappings.get(input.targetUserId)) + : targetWorkosUserId; + if (lockedTargetWorkosUserId !== targetWorkosUserId + || !await lockExactTargetCredential(client, targetWorkosUserId)) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'target_invalid' }; + } + if (input.surface === 'slack' + && (!input.slackActorUserId + || liveMappedWorkosUserId(leadershipState.slackMappings.get(input.slackActorUserId)) + !== actor.authenticated_user_id)) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'authorization_changed' }; + } + const exactLeader = hasExactCommitteeLeadership( + leadershipState, + actor.authenticated_user_id, + ); + if (!grantMatchesSnapshot(credentialGrant, admissionSnapshot)) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'authorization_changed' }; + } + if (!exactLeader) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'not_committee_leader' }; + } + + // Epoch is locked only after the concrete authority rows. Existing + // binding/grant/leadership revokers use that same resource-then-epoch + // order, avoiding the inverse advisory-lock path this boundary replaced. + await lockAuthorizationEpoch(client, actor.authenticated_user_id); + const actorAfterEpochLock = await lockActorState(client, actor.authenticated_user_id); + if (!actorAfterEpochLock || !actorMatchesSnapshot(actorAfterEpochLock, input.principal)) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'authorization_changed' }; + } + + let directMembership: boolean; + try { + directMembership = await hasActiveWorkOSMembership( + workos, + actor.authenticated_user_id, + organizationId, + ); + } catch (error) { + logger.warn({ err: error }, 'Committee leader WorkOS authorization unavailable'); + await client.query('ROLLBACK'); + return { status: 'unavailable', source: 'workos' }; + } + if (!directMembership && !credentialGrant) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'organization_forbidden' }; + } + + await dependencies.afterProviderAuthorization?.(); + + // These locked reads are intentionally repeated after the external call. + // The mutation must consume the same exact authority/epoch it admitted. + const actorImmediatelyBeforeMutation = await lockActorState( + client, + input.principal.authenticatedUserId, + ); + if (!actorImmediatelyBeforeMutation + || !actorMatchesSnapshot(actorImmediatelyBeforeMutation, input.principal)) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'authorization_changed' }; + } + const leadershipImmediatelyBeforeMutation = await lockCommitteeLeadershipState( + client, + committee.id, + [input.targetUserId, input.slackActorUserId ?? ''], + ); + const leaderImmediatelyBeforeMutation = hasExactCommitteeLeadership( + leadershipImmediatelyBeforeMutation, + actor.authenticated_user_id, + ); + const grantImmediatelyBeforeMutation = await getActiveCredentialGrant( + client, + actor.authenticated_user_id, + organizationId, + ); + if (!leaderImmediatelyBeforeMutation) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'not_committee_leader' }; + } + if (!grantMatchesSnapshot(grantImmediatelyBeforeMutation, admissionSnapshot)) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'authorization_changed' }; + } + if (!directMembership && !grantImmediatelyBeforeMutation) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'organization_forbidden' }; + } + + if (targetCredential.kind === 'slack' + && liveMappedWorkosUserId( + leadershipImmediatelyBeforeMutation.slackMappings.get(input.targetUserId), + ) !== targetWorkosUserId) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'target_invalid' }; + } + + if (input.surface === 'slack' + && (!input.slackActorUserId + || liveMappedWorkosUserId( + leadershipImmediatelyBeforeMutation.slackMappings.get(input.slackActorUserId), + ) + !== actor.authenticated_user_id)) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'authorization_changed' }; + } + + const commonResult = { + committeeId: committee.id, + committeeName: committee.name, + committeeType: committee.committee_type, + slackChannelId: committee.slack_channel_id, + targetWorkosUserId, + }; + + if (input.action === 'remove' + && await targetBelongsToActorIdentity(client, targetWorkosUserId, actor.identity_id)) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'self_removal_forbidden' }; + } + + const targetAlreadyLeads = hasExactCommitteeLeadership( + leadershipImmediatelyBeforeMutation, + targetWorkosUserId, + ); + if ((input.action === 'add' && targetAlreadyLeads) + || (input.action === 'remove' && !targetAlreadyLeads)) { + await client.query('ROLLBACK'); + return { + status: 'unchanged', + reason: input.action === 'add' ? 'already_leader' : 'not_leader', + ...commonResult, + }; + } + + // WorkOS is deliberately consulted a second time after the revocation + // barrier. The transaction already holds every local authority row, and + // the local actor/epoch/grant/leadership reads above are the final DB + // revalidation before this provider check and the write. + let directMembershipImmediatelyBeforeMutation: boolean; + try { + directMembershipImmediatelyBeforeMutation = await hasActiveWorkOSMembership( + workos, + actor.authenticated_user_id, + organizationId, + ); + } catch (error) { + logger.warn({ err: error }, 'Committee leader final WorkOS authorization unavailable'); + await client.query('ROLLBACK'); + return { status: 'unavailable', source: 'workos' }; + } + if (!directMembershipImmediatelyBeforeMutation && !grantImmediatelyBeforeMutation) { + await client.query('ROLLBACK'); + return { status: 'forbidden', reason: 'organization_forbidden' }; + } + + const mutation = input.action === 'add' + ? await client.query( + `INSERT INTO working_group_leaders (working_group_id, user_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + RETURNING user_id`, + [committee.id, targetWorkosUserId], + ) + : await client.query( + `DELETE FROM working_group_leaders + WHERE working_group_id = $1 + AND ( + user_id = $2 + OR user_id IN ( + SELECT slack_user_id + FROM slack_user_mappings + WHERE workos_user_id = $2 + ) + ) + RETURNING user_id`, + [committee.id, targetWorkosUserId], + ); + + if (mutation.rowCount === 0) { + await client.query('ROLLBACK'); + return { + status: 'unchanged', + reason: input.action === 'add' ? 'already_leader' : 'not_leader', + ...commonResult, + }; + } + + if (input.action === 'add') { + await client.query( + `INSERT INTO working_group_memberships ( + working_group_id, workos_user_id, user_email, + workos_organization_id, added_by_user_id, status + ) VALUES ($1, $2, $3, $4, $5, 'active') + ON CONFLICT (working_group_id, workos_user_id) + DO UPDATE SET status = 'active', updated_at = NOW()`, + [ + committee.id, + targetWorkosUserId, + input.targetEmail ?? null, + organizationId, + actor.authenticated_user_id, + ], + ); + } + + // Leadership changes alter the target credential's authority. The bump is + // in this transaction after the deterministically ordered committee and + // mapping locks, so a stale long-lived snapshot cannot survive the commit. + const targetAuthorizationEpoch = await bumpTargetAuthorizationEpochExactlyOnce( + client, + targetWorkosUserId, + ); + + const auditDetails = { + actor_authenticated_workos_user_id: actor.authenticated_user_id, + actor_identity_id: actor.identity_id, + actor_canonical_workos_user_id: actor.canonical_user_id, + target_workos_user_id: targetWorkosUserId, + target_authorization_epoch: targetAuthorizationEpoch, + committee_slug: committee.slug, + authorization_epoch: actor.authorization_epoch, + authorization_source: directMembershipImmediatelyBeforeMutation + ? 'workos' + : 'credential_grant', + surface: input.surface, + ...(input.slackActorUserId ? { slack_actor_user_id: input.slackActorUserId } : {}), + }; + const auditAction = input.action === 'add' + ? 'add_committee_co_leader' + : 'remove_committee_co_leader'; + const audit = await client.query( + `INSERT INTO registry_audit_log ( + workos_organization_id, workos_user_id, action, + resource_type, resource_id, details + ) VALUES ($1, $2, $3, 'working_group_leader', $4, $5) + RETURNING id, workos_organization_id, workos_user_id, action, + resource_type, resource_id, details`, + [ + organizationId, + actor.authenticated_user_id, + auditAction, + committee.id, + JSON.stringify(auditDetails), + ], + ); + const insertedAudit = audit.rows[0]; + if (audit.rowCount !== 1 + || !insertedAudit + || !insertedAudit.id + || insertedAudit.workos_organization_id !== organizationId + || insertedAudit.workos_user_id !== actor.authenticated_user_id + || insertedAudit.action !== auditAction + || insertedAudit.resource_type !== 'working_group_leader' + || insertedAudit.resource_id !== committee.id + || !isDeepStrictEqual(insertedAudit.details, auditDetails)) { + throw new Error('Committee leader audit insert did not persist exactly one matching row'); + } + + await client.query('COMMIT'); + const committedEvent: CommitteeLeaderMutationCommittedEvent = { + action: input.action === 'add' ? 'added' : 'removed', + committeeId: committee.id, + organizationId, + targetWorkosUserId, + }; + try { + (dependencies.afterCommit ?? scheduleLeadershipSideEffects)(committedEvent); + } catch (error) { + // The mutation is already committed. Preserve the historical + // fire-and-forget contract without misreporting a committed write. + logger.error({ err: error, workingGroupId: committee.id }, 'Leadership side effect scheduling failed'); + } + return { + status: 'mutated', + action: committedEvent.action, + ...commonResult, + }; + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined); + logger.warn({ err: error }, 'Committee leader mutation database transaction unavailable'); + return { status: 'unavailable', source: 'database' }; + } finally { + client.release(); + } +} diff --git a/server/tests/integration/committee-leader-mutation-authorization.test.ts b/server/tests/integration/committee-leader-mutation-authorization.test.ts new file mode 100644 index 00000000000..2379b34a9ba --- /dev/null +++ b/server/tests/integration/committee-leader-mutation-authorization.test.ts @@ -0,0 +1,744 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Pool } 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 { bumpAuthorizationEpochs } from '../../src/db/authorization-epoch-db.js'; +import { loadAuthorizationSnapshot } from '../../src/db/user-authorization-snapshot-db.js'; +import { mutateCommitteeLeader } from '../../src/services/committee-leader-mutation.js'; +import { createCommitteeLeaderToolHandlers } from '../../src/addie/mcp/committee-leader-tools.js'; + +const enforcementProvider = vi.hoisted(() => ({ membership: true, outage: false })); +const sideEffects = vi.hoisted(() => ({ + journey: vi.fn(async () => undefined), + points: vi.fn(async () => undefined), +})); +vi.mock('../../src/auth/workos-client.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAuthorizationEnforcementWorkos: () => provider(enforcementProvider), + }; +}); +vi.mock('../../src/addie/services/journey-computation.js', () => ({ + computeJourneyStage: sideEffects.journey, +})); +vi.mock('../../src/db/community-db.js', () => ({ + CommunityDatabase: class { + awardPoints = sideEffects.points; + }, +})); + +const CREDENTIAL_A = 'user_committee_exact_a'; +const CREDENTIAL_B = 'user_committee_exact_b'; +const TARGET = 'user_committee_exact_target'; +const ORGANIZATION_ID = 'org_committee_exact'; +const COMMITTEE_SLUG = 'committee-exact-authorization'; +const SECOND_COMMITTEE_SLUG = 'committee-exact-authorization-second'; +const SLACK_ACTOR = 'U_COMMITTEE_EXACT_ACTOR'; +const SLACK_TARGET = 'U_COMMITTEE_EXACT_TARGET'; +const USER_IDS = [CREDENTIAL_A, CREDENTIAL_B, TARGET]; + +function provider(options: { outage?: boolean; membership?: boolean } = {}) { + return { + userManagement: { + listOrganizationMemberships: vi.fn(async ({ userId, organizationId }) => { + if (options.outage) throw new Error('provider unavailable'); + return { + data: options.membership === false ? [] : [{ + id: `membership_${userId}`, + userId, + organizationId, + status: 'active', + role: { slug: 'member' }, + }], + }; + }), + }, + } as any; +} + +describe('committee leader exact-credential mutation authorization', () => { + let pool: Pool; + let committeeId: string; + + beforeAll(async () => { + pool = initializeDatabase({ + connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test', + }); + await runMigrations(); + }, 60_000); + + async function cleanup() { + await pool.query('DROP TRIGGER IF EXISTS committee_audit_drop_test ON registry_audit_log'); + await pool.query('DROP FUNCTION IF EXISTS committee_audit_drop_test()'); + await pool.query('DELETE FROM registry_audit_log WHERE workos_organization_id = $1', [ORGANIZATION_ID]); + await pool.query('DELETE FROM working_groups WHERE slug = ANY($1)', [ + [COMMITTEE_SLUG, SECOND_COMMITTEE_SLUG], + ]); + await pool.query('DELETE FROM organization_credential_grants WHERE workos_organization_id = $1', [ORGANIZATION_ID]); + await pool.query('DELETE FROM organization_memberships WHERE workos_organization_id = $1', [ORGANIZATION_ID]); + await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', [ORGANIZATION_ID]); + await pool.query( + 'DELETE FROM slack_user_mappings WHERE slack_user_id = ANY($1)', + [[SLACK_ACTOR, SLACK_TARGET]], + ); + 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]); + if (identities.rows.length > 0) { + await pool.query('DELETE FROM identities WHERE id = ANY($1)', [identities.rows.map((row) => row.identity_id)]); + } + } + + beforeEach(async () => { + vi.restoreAllMocks(); + sideEffects.journey.mockClear(); + sideEffects.points.mockClear(); + enforcementProvider.membership = true; + enforcementProvider.outage = false; + await cleanup(); + await pool.query( + `INSERT INTO users ( + workos_user_id, email, email_verified, workos_created_at, workos_updated_at + ) VALUES + ($1, 'credential-a@example.test', true, NOW(), NOW()), + ($2, 'credential-b@example.test', true, NOW(), NOW()), + ($3, 'target@example.test', true, NOW(), NOW())`, + USER_IDS, + ); + await pool.query( + `INSERT INTO organizations (workos_organization_id, name) + VALUES ($1, 'Pinnacle Agency')`, + [ORGANIZATION_ID], + ); + const committee = await pool.query<{ id: string }>( + `INSERT INTO working_groups (name, slug, committee_type) + VALUES ('Exact authorization committee', $1, 'working_group') + RETURNING id`, + [COMMITTEE_SLUG], + ); + committeeId = committee.rows[0].id; + }); + + afterAll(async () => { + vi.restoreAllMocks(); + if (pool) await cleanup(); + await closeDatabase(); + }); + + async function link(primaryId: string, secondaryId: string) { + const oldIdentity = await pool.query<{ identity_id: string }>( + 'SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1', + [secondaryId], + ); + await 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], + ); + await pool.query('DELETE FROM identities WHERE id = $1', [oldIdentity.rows[0].identity_id]); + } + + async function snapshot(userId: string) { + const value = await loadAuthorizationSnapshot(userId, ORGANIZATION_ID); + expect(value).not.toBeNull(); + return value!; + } + + async function insertSlackMapping(input: { + slackUserId: string; + workosUserId: string | null; + status?: 'mapped' | 'unmapped' | 'pending_verification'; + deleted?: boolean | null; + bot?: boolean | null; + }) { + await pool.query( + `INSERT INTO slack_user_mappings ( + slack_user_id, workos_user_id, mapping_status, mapping_source, + slack_is_deleted, slack_is_bot + ) VALUES ($1, $2, $3, $4, $5, $6)`, + [ + input.slackUserId, + input.workosUserId, + input.status ?? 'mapped', + input.status === 'mapped' ? 'user_claimed' : null, + input.deleted ?? false, + input.bot ?? false, + ], + ); + } + + async function waitUntilConnectionBlocksAnother(blockerPid: number): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + const result = await pool.query<{ blocked: boolean }>( + `SELECT EXISTS ( + SELECT 1 + FROM pg_stat_activity + WHERE $1 = ANY(pg_blocking_pids(pid)) + ) AS blocked`, + [blockerPid], + ); + if (result.rows[0].blocked) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`mutation did not reach the expected lock barrier for backend ${blockerPid}`); + } + + it.each([ + { name: 'canonical leader cannot elevate linked non-leader', primary: CREDENTIAL_A, secondary: CREDENTIAL_B, leader: CREDENTIAL_A, denied: CREDENTIAL_B }, + { name: 'linked exact leader does not lose authority through non-leader canonical identity', primary: CREDENTIAL_A, secondary: CREDENTIAL_B, leader: CREDENTIAL_B, denied: CREDENTIAL_A }, + ])('$name', async ({ primary, secondary, leader, denied }) => { + await link(primary, secondary); + await pool.query( + 'INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ($1, $2)', + [committeeId, leader], + ); + + const deniedHandler = createCommitteeLeaderToolHandlers( + { workos_user: { workos_user_id: primary } }, + undefined, + { + principal: { + id: primary, + authWorkosUserId: denied, + authorizationSnapshot: await snapshot(denied), + }, + surface: 'web', + }, + ).get('add_committee_co_leader')!; + expect(await deniedHandler({ + committee_slug: COMMITTEE_SLUG, + organization_id: ORGANIZATION_ID, + user_id: TARGET, + })).toContain('not authorized'); + expect((await pool.query( + 'SELECT 1 FROM working_group_leaders WHERE working_group_id = $1 AND user_id = $2', + [committeeId, TARGET], + )).rowCount).toBe(0); + + const leaderHandler = createCommitteeLeaderToolHandlers( + { workos_user: { workos_user_id: primary } }, + undefined, + { + principal: { + id: primary, + authWorkosUserId: leader, + authorizationSnapshot: await snapshot(leader), + }, + surface: 'web', + }, + ).get('add_committee_co_leader')!; + expect(await leaderHandler({ + committee_slug: COMMITTEE_SLUG, + organization_id: ORGANIZATION_ID, + user_id: TARGET, + })).toContain('Successfully added'); + expect(sideEffects.journey).toHaveBeenCalledWith( + ORGANIZATION_ID, + 'leadership_change', + `working_group:${committeeId}`, + ); + expect(sideEffects.points).toHaveBeenCalledWith( + TARGET, + 'wg_leadership', + 30, + committeeId, + 'working_group', + ); + + const audit = await pool.query<{ + workos_user_id: string; + details: Record; + }>( + `SELECT workos_user_id, details + FROM registry_audit_log + WHERE workos_organization_id = $1 + AND action = 'add_committee_co_leader'`, + [ORGANIZATION_ID], + ); + expect(audit.rows).toHaveLength(1); + expect(audit.rows[0].workos_user_id).toBe(leader); + expect(audit.rows[0].details).toMatchObject({ + actor_authenticated_workos_user_id: leader, + actor_identity_id: (await snapshot(leader)).identityId, + actor_canonical_workos_user_id: primary, + target_workos_user_id: TARGET, + target_authorization_epoch: '1', + committee_slug: COMMITTEE_SLUG, + }); + expect((await snapshot(TARGET)).authorizationEpoch).toBe('1'); + }); + + it('removes a co-leader and bumps the target credential epoch in the same commit', async () => { + await pool.query( + `INSERT INTO working_group_leaders (working_group_id, user_id) + VALUES ($1, $2), ($1, $3)`, + [committeeId, CREDENTIAL_A, TARGET], + ); + await insertSlackMapping({ slackUserId: SLACK_ACTOR, workosUserId: CREDENTIAL_A }); + const result = await mutateCommitteeLeader({ + action: 'remove', principal: await snapshot(CREDENTIAL_A), + selectedOrganizationId: ORGANIZATION_ID, committeeSlug: COMMITTEE_SLUG, + targetUserId: TARGET, surface: 'slack', slackActorUserId: SLACK_ACTOR, + }, { getWorkos: () => provider() }); + expect(result).toMatchObject({ status: 'mutated', action: 'removed' }); + expect((await pool.query( + 'SELECT 1 FROM working_group_leaders WHERE working_group_id = $1 AND user_id = $2', + [committeeId, TARGET], + )).rowCount).toBe(0); + expect((await snapshot(TARGET)).authorizationEpoch).toBe('1'); + }); + + it('accepts a live mapped Slack target and bumps that exact WorkOS credential once', async () => { + await pool.query( + 'INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ($1, $2)', + [committeeId, CREDENTIAL_A], + ); + await insertSlackMapping({ slackUserId: SLACK_TARGET, workosUserId: TARGET }); + + const result = await mutateCommitteeLeader({ + action: 'add', principal: await snapshot(CREDENTIAL_A), + selectedOrganizationId: ORGANIZATION_ID, committeeSlug: COMMITTEE_SLUG, + targetUserId: SLACK_TARGET, surface: 'web', + }, { getWorkos: () => provider() }); + + expect(result).toMatchObject({ + status: 'mutated', action: 'added', targetWorkosUserId: TARGET, + }); + expect((await snapshot(TARGET)).authorizationEpoch).toBe('1'); + expect((await pool.query( + 'SELECT epoch FROM authorization_epochs WHERE workos_user_id = $1', + [TARGET], + )).rows).toEqual([{ epoch: '1' }]); + }); + + it('accepts an explicit exact-credential grant only after a successful provider consultation', async () => { + await pool.query( + 'INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ($1, $2)', + [committeeId, CREDENTIAL_A], + ); + await pool.query( + `INSERT INTO organization_credential_grants ( + workos_user_id, workos_organization_id, role, granted_by_workos_user_id + ) VALUES ($1, $2, 'member', $1)`, + [CREDENTIAL_A, ORGANIZATION_ID], + ); + const result = await mutateCommitteeLeader({ + action: 'add', principal: await snapshot(CREDENTIAL_A), + selectedOrganizationId: ORGANIZATION_ID, committeeSlug: COMMITTEE_SLUG, + targetUserId: TARGET, surface: 'web', + }, { getWorkos: () => provider({ membership: false }) }); + expect(result).toMatchObject({ status: 'mutated', action: 'added' }); + const audit = await pool.query<{ details: Record }>( + `SELECT details FROM registry_audit_log + WHERE workos_organization_id = $1 AND action = 'add_committee_co_leader'`, + [ORGANIZATION_ID], + ); + expect(audit.rows[0].details.authorization_source).toBe('credential_grant'); + }); + + it('requires an explicit organization and never chooses one from cached membership state', async () => { + await pool.query( + 'INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ($1, $2)', + [committeeId, CREDENTIAL_A], + ); + const principal = await snapshot(CREDENTIAL_A); + const result = await mutateCommitteeLeader({ + action: 'add', principal, selectedOrganizationId: '', committeeSlug: COMMITTEE_SLUG, + targetUserId: TARGET, surface: 'web', + }, { getWorkos: () => provider() }); + expect(result).toEqual({ status: 'forbidden', reason: 'organization_required' }); + }); + + it('fails closed as typed unavailable on provider and database outages', async () => { + await pool.query( + 'INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ($1, $2)', + [committeeId, CREDENTIAL_A], + ); + const principal = await snapshot(CREDENTIAL_A); + const providerFailure = await mutateCommitteeLeader({ + action: 'add', principal, selectedOrganizationId: ORGANIZATION_ID, + committeeSlug: COMMITTEE_SLUG, targetUserId: TARGET, surface: 'web', + }, { getWorkos: () => provider({ outage: true }) }); + expect(providerFailure).toEqual({ status: 'unavailable', source: 'workos' }); + + vi.spyOn(database, 'getPool').mockImplementation(() => { + throw new Error('database unavailable'); + }); + const databaseFailure = await mutateCommitteeLeader({ + action: 'add', principal, selectedOrganizationId: ORGANIZATION_ID, + committeeSlug: COMMITTEE_SLUG, targetUserId: TARGET, surface: 'web', + }, { getWorkos: () => provider() }); + expect(databaseFailure).toEqual({ status: 'unavailable', source: 'database' }); + }); + + it('rechecks WorkOS after the provider barrier and denies a membership revoked in between', async () => { + await pool.query( + 'INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ($1, $2)', + [committeeId, CREDENTIAL_A], + ); + const principal = await snapshot(CREDENTIAL_A); + let membership = true; + const statefulProvider = provider(); + statefulProvider.userManagement.listOrganizationMemberships.mockImplementation( + async ({ userId, organizationId }: { userId: string; organizationId: string }) => ({ + data: membership ? [{ + id: `membership_${userId}`, + userId, + organizationId, + status: 'active', + role: { slug: 'member' }, + }] : [], + }), + ); + const result = await mutateCommitteeLeader({ + action: 'add', principal, selectedOrganizationId: ORGANIZATION_ID, + committeeSlug: COMMITTEE_SLUG, targetUserId: TARGET, surface: 'web', + }, { + getWorkos: () => statefulProvider, + afterProviderAuthorization: async () => { membership = false; }, + }); + expect(result).toEqual({ status: 'forbidden', reason: 'organization_forbidden' }); + expect(statefulProvider.userManagement.listOrganizationMemberships).toHaveBeenCalledTimes(2); + expect((await pool.query( + 'SELECT 1 FROM working_group_leaders WHERE working_group_id = $1 AND user_id = $2', + [committeeId, TARGET], + )).rowCount).toBe(0); + }); + + it('fails closed when WorkOS becomes unavailable at the final provider recheck', async () => { + await pool.query( + 'INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ($1, $2)', + [committeeId, CREDENTIAL_A], + ); + const principal = await snapshot(CREDENTIAL_A); + const statefulProvider = provider(); + let calls = 0; + statefulProvider.userManagement.listOrganizationMemberships.mockImplementation( + async ({ userId, organizationId }: { userId: string; organizationId: string }) => { + calls += 1; + if (calls === 2) throw new Error('provider unavailable at final recheck'); + return { + data: [{ + id: `membership_${userId}`, + userId, + organizationId, + status: 'active', + role: { slug: 'member' }, + }], + }; + }, + ); + const result = await mutateCommitteeLeader({ + action: 'add', principal, selectedOrganizationId: ORGANIZATION_ID, + committeeSlug: COMMITTEE_SLUG, targetUserId: TARGET, surface: 'web', + }, { + getWorkos: () => statefulProvider, + afterProviderAuthorization: async () => undefined, + }); + expect(result).toEqual({ status: 'unavailable', source: 'workos' }); + expect((await pool.query( + 'SELECT 1 FROM working_group_leaders WHERE working_group_id = $1 AND user_id = $2', + [committeeId, TARGET], + )).rowCount).toBe(0); + }); + + it('serializes Slack actor provenance and denies a reassignment/replay that wins the barrier', async () => { + await pool.query( + 'INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ($1, $2)', + [committeeId, SLACK_ACTOR], + ); + await insertSlackMapping({ slackUserId: SLACK_ACTOR, workosUserId: CREDENTIAL_A }); + const principal = await snapshot(CREDENTIAL_A); + const revoker = await pool.connect(); + try { + await revoker.query('BEGIN'); + const blockerPid = Number((await revoker.query<{ pid: number }>( + 'SELECT pg_backend_pid() AS pid', + )).rows[0].pid); + await revoker.query( + 'SELECT 1 FROM slack_user_mappings WHERE slack_user_id = $1 FOR UPDATE', + [SLACK_ACTOR], + ); + const pendingMutation = mutateCommitteeLeader({ + action: 'add', principal, selectedOrganizationId: ORGANIZATION_ID, + committeeSlug: COMMITTEE_SLUG, targetUserId: TARGET, surface: 'slack', + slackActorUserId: SLACK_ACTOR, + }, { getWorkos: () => provider() }); + + await waitUntilConnectionBlocksAnother(blockerPid); + + await revoker.query( + `UPDATE slack_user_mappings + SET workos_user_id = $1, updated_at = NOW() + WHERE slack_user_id = $2`, + [CREDENTIAL_B, SLACK_ACTOR], + ); + await revoker.query('COMMIT'); + + expect(await pendingMutation).toEqual({ status: 'forbidden', reason: 'authorization_changed' }); + expect((await pool.query( + 'SELECT 1 FROM working_group_leaders WHERE working_group_id = $1 AND user_id = $2', + [committeeId, TARGET], + )).rowCount).toBe(0); + } finally { + await revoker.query('ROLLBACK').catch(() => undefined); + revoker.release(); + } + }); + + it.each([ + { + name: 'revoked/unmapped actor', + mapping: { workosUserId: null, status: 'unmapped' as const }, + }, + { + name: 'pending actor status', + mapping: { workosUserId: CREDENTIAL_A, status: 'pending_verification' as const }, + }, + { + name: 'deleted actor', + mapping: { workosUserId: CREDENTIAL_A, deleted: true }, + }, + { + name: 'bot actor', + mapping: { workosUserId: CREDENTIAL_A, bot: true }, + }, + { + name: 'mapped actor without a credential', + mapping: { workosUserId: null, status: 'mapped' as const }, + }, + ])('rejects $name under the transaction mapping lock', async ({ mapping }) => { + await pool.query( + 'INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ($1, $2)', + [committeeId, SLACK_ACTOR], + ); + await insertSlackMapping({ slackUserId: SLACK_ACTOR, ...mapping }); + const result = await mutateCommitteeLeader({ + action: 'add', principal: await snapshot(CREDENTIAL_A), + selectedOrganizationId: ORGANIZATION_ID, committeeSlug: COMMITTEE_SLUG, + targetUserId: TARGET, surface: 'slack', slackActorUserId: SLACK_ACTOR, + }, { getWorkos: () => provider() }); + + expect(result).toEqual({ status: 'forbidden', reason: 'authorization_changed' }); + expect((await pool.query( + 'SELECT 1 FROM working_group_leaders WHERE working_group_id = $1 AND user_id = $2', + [committeeId, TARGET], + )).rowCount).toBe(0); + expect((await pool.query( + `SELECT 1 FROM registry_audit_log + WHERE workos_organization_id = $1 AND action = 'add_committee_co_leader'`, + [ORGANIZATION_ID], + )).rowCount).toBe(0); + }); + + it.each([ + { name: 'arbitrary id', mapping: null }, + { + name: 'unmapped Slack id', + mapping: { workosUserId: TARGET, status: 'unmapped' as const }, + }, + { + name: 'deleted Slack id', + mapping: { workosUserId: TARGET, deleted: true }, + }, + { + name: 'bot Slack id', + mapping: { workosUserId: TARGET, bot: true }, + }, + { + name: 'inconsistent mapped Slack id', + mapping: { workosUserId: null, status: 'mapped' as const }, + }, + ])('rejects a non-live target: $name', async ({ mapping }) => { + await pool.query( + 'INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ($1, $2)', + [committeeId, CREDENTIAL_A], + ); + if (mapping) { + await insertSlackMapping({ slackUserId: SLACK_TARGET, ...mapping }); + } + const result = await mutateCommitteeLeader({ + action: 'add', principal: await snapshot(CREDENTIAL_A), + selectedOrganizationId: ORGANIZATION_ID, committeeSlug: COMMITTEE_SLUG, + targetUserId: mapping ? SLACK_TARGET : 'not-a-live-workos-credential', surface: 'web', + }, { getWorkos: () => provider() }); + + expect(result).toEqual({ status: 'forbidden', reason: 'target_invalid' }); + expect((await pool.query( + `SELECT 1 FROM registry_audit_log + WHERE workos_organization_id = $1 AND action = 'add_committee_co_leader'`, + [ORGANIZATION_ID], + )).rowCount).toBe(0); + }); + + it.each([ + { name: 'direct credential', linked: false }, + { name: 'linked credential on the same identity', linked: true }, + ])('never commits self-removal for a $name', async ({ linked }) => { + if (linked) await link(CREDENTIAL_A, CREDENTIAL_B); + const actorId = linked ? CREDENTIAL_B : CREDENTIAL_A; + const targetId = CREDENTIAL_A; + await pool.query( + `INSERT INTO working_group_leaders (working_group_id, user_id) + VALUES ($1, $2), ($1, $3) + ON CONFLICT DO NOTHING`, + [committeeId, actorId, targetId], + ); + + const result = await mutateCommitteeLeader({ + action: 'remove', principal: await snapshot(actorId), + selectedOrganizationId: ORGANIZATION_ID, committeeSlug: COMMITTEE_SLUG, + targetUserId: targetId, surface: 'web', + }, { getWorkos: () => provider() }); + expect(result).toEqual({ status: 'forbidden', reason: 'self_removal_forbidden' }); + + const committedRead = await pool.query( + 'SELECT 1 FROM working_group_leaders WHERE working_group_id = $1 AND user_id = $2', + [committeeId, targetId], + ); + expect(committedRead.rowCount).toBe(1); + expect((await pool.query( + `SELECT 1 FROM registry_audit_log + WHERE workos_organization_id = $1 AND action = 'remove_committee_co_leader'`, + [ORGANIZATION_ID], + )).rowCount).toBe(0); + }); + + it('rolls back mutation, membership, epoch, and side effects when audit insertion is suppressed', async () => { + await pool.query( + 'INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ($1, $2)', + [committeeId, CREDENTIAL_A], + ); + await pool.query(` + CREATE FUNCTION committee_audit_drop_test() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.action = 'add_committee_co_leader' THEN + RETURN NULL; + END IF; + RETURN NEW; + END; + $$ + `); + await pool.query(` + CREATE TRIGGER committee_audit_drop_test + BEFORE INSERT ON registry_audit_log + FOR EACH ROW EXECUTE FUNCTION committee_audit_drop_test() + `); + + const result = await mutateCommitteeLeader({ + action: 'add', principal: await snapshot(CREDENTIAL_A), + selectedOrganizationId: ORGANIZATION_ID, committeeSlug: COMMITTEE_SLUG, + targetUserId: TARGET, surface: 'web', + }, { getWorkos: () => provider() }); + expect(result).toEqual({ status: 'unavailable', source: 'database' }); + expect((await pool.query( + 'SELECT 1 FROM working_group_leaders WHERE working_group_id = $1 AND user_id = $2', + [committeeId, TARGET], + )).rowCount).toBe(0); + expect((await pool.query( + 'SELECT 1 FROM working_group_memberships WHERE working_group_id = $1 AND workos_user_id = $2', + [committeeId, TARGET], + )).rowCount).toBe(0); + expect((await snapshot(TARGET)).authorizationEpoch).toBe('0'); + expect((await pool.query( + `SELECT 1 FROM registry_audit_log + WHERE workos_organization_id = $1 AND action = 'add_committee_co_leader'`, + [ORGANIZATION_ID], + )).rowCount).toBe(0); + expect(sideEffects.journey).not.toHaveBeenCalled(); + expect(sideEffects.points).not.toHaveBeenCalled(); + }); + + it('follows resource-then-epoch order and denies a grant revocation that wins the barrier', async () => { + await pool.query( + 'INSERT INTO working_group_leaders (working_group_id, user_id) VALUES ($1, $2)', + [committeeId, CREDENTIAL_A], + ); + const grant = await pool.query<{ id: string }>( + `INSERT INTO organization_credential_grants ( + workos_user_id, workos_organization_id, role, granted_by_workos_user_id + ) VALUES ($1, $2, 'member', $1) + RETURNING id`, + [CREDENTIAL_A, ORGANIZATION_ID], + ); + const principal = await snapshot(CREDENTIAL_A); + const revoker = await pool.connect(); + try { + await revoker.query('BEGIN'); + const blockerPid = Number((await revoker.query<{ pid: number }>( + 'SELECT pg_backend_pid() AS pid', + )).rows[0].pid); + await revoker.query( + 'SELECT 1 FROM organization_credential_grants WHERE id = $1 FOR UPDATE', + [grant.rows[0].id], + ); + const pendingMutation = mutateCommitteeLeader({ + action: 'add', principal, selectedOrganizationId: ORGANIZATION_ID, + committeeSlug: COMMITTEE_SLUG, targetUserId: TARGET, surface: 'web', + }, { getWorkos: () => provider({ membership: false }) }); + + await waitUntilConnectionBlocksAnother(blockerPid); + + await revoker.query( + `UPDATE organization_credential_grants + SET revoked_at = NOW(), revoked_by_workos_user_id = $1 + WHERE id = $2`, + [CREDENTIAL_B, grant.rows[0].id], + ); + await bumpAuthorizationEpochs(revoker, [CREDENTIAL_A]); + await revoker.query('COMMIT'); + + expect(await pendingMutation).toEqual({ status: 'forbidden', reason: 'authorization_changed' }); + expect((await pool.query( + 'SELECT 1 FROM working_group_leaders WHERE working_group_id = $1 AND user_id = $2', + [committeeId, TARGET], + )).rowCount).toBe(0); + } finally { + await revoker.query('ROLLBACK').catch(() => undefined); + revoker.release(); + } + }); + + it('serializes opposite actor/target credentials in one deterministic order', async () => { + const secondCommittee = await pool.query<{ id: string }>( + `INSERT INTO working_groups (name, slug, committee_type) + VALUES ('Second exact authorization committee', $1, 'working_group') + RETURNING id`, + [SECOND_COMMITTEE_SLUG], + ); + await pool.query( + `INSERT INTO working_group_leaders (working_group_id, user_id) + VALUES ($1, $2), ($3, $4)`, + [committeeId, CREDENTIAL_A, secondCommittee.rows[0].id, CREDENTIAL_B], + ); + const [principalA, principalB] = await Promise.all([ + snapshot(CREDENTIAL_A), + snapshot(CREDENTIAL_B), + ]); + + const results = await Promise.all([ + mutateCommitteeLeader({ + action: 'add', principal: principalA, selectedOrganizationId: ORGANIZATION_ID, + committeeSlug: COMMITTEE_SLUG, targetUserId: CREDENTIAL_B, surface: 'web', + }, { getWorkos: () => provider() }), + mutateCommitteeLeader({ + action: 'add', principal: principalB, selectedOrganizationId: ORGANIZATION_ID, + committeeSlug: SECOND_COMMITTEE_SLUG, targetUserId: CREDENTIAL_A, surface: 'web', + }, { getWorkos: () => provider() }), + ]); + + expect(results.filter((result) => result.status === 'mutated')).toHaveLength(1); + expect(results.filter((result) => + result.status === 'forbidden' && result.reason === 'authorization_changed' + )).toHaveLength(1); + }); +}); diff --git a/server/tests/unit/addie-chat-org-selection.test.ts b/server/tests/unit/addie-chat-org-selection.test.ts index 23d84aac9fc..dc272047b94 100644 --- a/server/tests/unit/addie-chat-org-selection.test.ts +++ b/server/tests/unit/addie-chat-org-selection.test.ts @@ -46,6 +46,11 @@ const threadMocks = vi.hoisted(() => ({ addMessageFeedback: vi.fn(), })); +const committeeMutationMocks = vi.hoisted(() => ({ + mutate: vi.fn(), + resolvePrincipal: vi.fn(), +})); + vi.mock('../../src/addie/member-context.js', () => ({ getWebMemberContext: memberContextMocks.getWebMemberContext, formatMemberContextForPrompt: memberContextMocks.formatMemberContextForPrompt, @@ -89,6 +94,11 @@ vi.mock('../../src/db/certification-db.js', () => ({ getProgress: vi.fn(), })); +vi.mock('../../src/services/committee-leader-mutation.js', () => ({ + mutateCommitteeLeader: committeeMutationMocks.mutate, + resolveCommitteeLeaderPrincipal: committeeMutationMocks.resolvePrincipal, +})); + vi.mock('../../src/addie/admin-status-lookup.js', () => ({ isWebUserAAOAdmin: vi.fn().mockResolvedValue(false), })); @@ -102,7 +112,26 @@ vi.mock('../../src/db/working-group-db.js', () => ({ vi.mock('../../src/middleware/auth.js', () => ({ optionalAuth: (req: any, _res: any, next: any) => { const id = req.get('x-test-user-id'); - if (id) req.user = { id, email: `${id}@example.com` }; + const authenticatedId = req.get('x-test-authenticated-user-id') || id; + if (id) { + req.user = { + id, + authWorkosUserId: authenticatedId === id ? undefined : authenticatedId, + email: `${authenticatedId}@example.com`, + authorizationSnapshot: Object.freeze({ + authenticatedUserId: authenticatedId, + canonicalUserId: id, + identityId: `identity_${id}`, + selectedOrganizationId: req.body?.organization_id ?? null, + authorizationEpoch: '7', + credential: Object.freeze({ + email: `${authenticatedId}@example.com`, emailVerified: true, + firstName: null, lastName: null, + }), + credentialGrant: null, + }), + }; + } next(); }, })); @@ -137,6 +166,8 @@ describe('prepareRequestWithMemberTools organization selection', () => { siHostMocks.hasCachedSiSession.mockReset(); siHostMocks.hasCachedSiSession.mockReturnValue(false); directoryMocks.createHandlers.mockClear(); + committeeMutationMocks.mutate.mockReset(); + committeeMutationMocks.resolvePrincipal.mockReset(); }); it('passes the selected organization id into web member context resolution', async () => { @@ -223,6 +254,20 @@ describe('mounted Addie web-thread ownership', () => { } beforeEach(() => { + memberContextMocks.getWebMemberContext.mockReset().mockResolvedValue({ + is_mapped: false, is_member: false, slack_linked: false, + }); + memberContextMocks.formatMemberContextForPrompt.mockReset().mockReturnValue(null); + siMocks.retrieve.mockReset().mockResolvedValue({ agents: [], retrieval_time_ms: 0 }); + siMocks.formatContext.mockReset(); + siHostMocks.hasCachedSiSession.mockReset().mockReturnValue(false); + chatClient.processMessage.mockReset().mockResolvedValue({ + text: 'Hello', tools_used: [], tool_executions: [], + }); + chatClient.processMessageStream.mockReset().mockImplementation(() => (async function* () { + yield { type: 'text', text: 'Hello' }; + yield { type: 'done', response: { text: 'Hello', tools_used: [], tool_executions: [] } }; + })()); threadMocks.getThreadByExternalId.mockReset(); threadMocks.getOrCreateThread.mockReset().mockResolvedValue({ thread_id: 'thread-anonymous-created', @@ -236,6 +281,77 @@ describe('mounted Addie web-thread ownership', () => { message_id: '33333333-3333-4333-8333-333333333333', }); threadMocks.addMessageFeedback.mockReset().mockResolvedValue(true); + committeeMutationMocks.mutate.mockReset(); + committeeMutationMocks.resolvePrincipal.mockReset(); + }); + + it('binds the mounted web mutation handler to authWorkosUserId in both linked directions', async () => { + const router = { + quickMatch: vi.fn().mockReturnValue(null), + route: vi.fn().mockResolvedValue({ + action: 'respond', tool_sets: ['committee_co_leaders'], confidence: 'high', + reason: 'committee leadership mutation', decision_method: 'llm', + }), + }; + threadMocks.getOrCreateThread.mockResolvedValue({ + thread_id: 'thread-web-exact-principal', + user_type: 'workos', + user_id: 'canonical_profile', + message_count: 0, + }); + committeeMutationMocks.mutate.mockImplementation(async (input: any) => + input.principal.authenticatedUserId === 'credential_leader' + ? { + status: 'mutated', action: 'added', committeeId: 'committee-id', + committeeName: 'Creative working group', committeeType: 'working_group', + slackChannelId: null, targetWorkosUserId: 'target-user', + } + : { status: 'forbidden', reason: 'not_committee_leader' } + ); + chatClient.processMessage.mockImplementation(async (_message, _history, requestTools) => { + const result = await requestTools.handlers.get('add_committee_co_leader')({ + organization_id: 'org_selected', + committee_slug: 'creative-working-group', + user_id: 'target-user', + }); + return { text: result, tools_used: [], tool_executions: [] }; + }); + + const denied = await request(app(router)) + .post('/api/addie/chat') + .set('x-test-user-id', 'credential_leader') + .set('x-test-authenticated-user-id', 'credential_unprivileged') + .send({ message: 'Add the co-leader', organization_id: 'org_selected' }); + expect(denied.status).toBe(200); + expect(denied.body.response).toContain('not authorized'); + expect(committeeMutationMocks.mutate.mock.calls[0][0].principal).toMatchObject({ + authenticatedUserId: 'credential_unprivileged', + canonicalUserId: 'credential_leader', + identityId: 'identity_credential_leader', + }); + + threadMocks.getOrCreateThread.mockResolvedValue({ + thread_id: 'thread-web-exact-principal-2', + user_type: 'workos', + user_id: 'credential_unprivileged', + message_count: 0, + }); + const allowed = await request(app(router)) + .post('/api/addie/chat') + .set('x-test-user-id', 'credential_unprivileged') + .set('x-test-authenticated-user-id', 'credential_leader') + .send({ message: 'Add the co-leader', organization_id: 'org_selected' }); + expect(allowed.status).toBe(200); + expect(allowed.body.response).toContain('Successfully added'); + expect(committeeMutationMocks.mutate.mock.calls[1][0]).toMatchObject({ + selectedOrganizationId: 'org_selected', + surface: 'web', + principal: { + authenticatedUserId: 'credential_leader', + canonicalUserId: 'credential_unprivileged', + identityId: 'identity_credential_unprivileged', + }, + }); }); it('hides another user\'s thread on the mounted GET route', async () => { diff --git a/server/tests/unit/addie/tavus-tool-selection.test.ts b/server/tests/unit/addie/tavus-tool-selection.test.ts index 2d4bd9be1eb..20c7e62dfba 100644 --- a/server/tests/unit/addie/tavus-tool-selection.test.ts +++ b/server/tests/unit/addie/tavus-tool-selection.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; import type { AddieTool } from '../../../src/addie/types.js'; -import { selectRoutedTavusVoiceTools } from '../../../src/routes/tavus.js'; +import { + buildVoiceRequestTools, + selectRoutedTavusVoiceTools, +} from '../../../src/routes/tavus.js'; import { getToolsForSets } from '../../../src/addie/tool-sets.js'; const tools: AddieTool[] = [ @@ -52,6 +55,51 @@ async function select( } describe('authenticated Tavus voice Addie tool routing', () => { + it.each([ + { + name: 'stored canonical A is leader while authenticated linked B is not', + ledGroups: [{ id: 'committee-led-by-a' }], + }, + { + name: 'authenticated linked B is leader while stored canonical A is not', + ledGroups: [], + }, + ])('omits committee mutation definitions and handlers when $name', async ({ ledGroups }) => { + const caller = 'credential_a'; + const canonical = 'credential_a'; + const memberContext = { + is_mapped: true, + is_member: true, + slack_linked: true, + workos_user: { + workos_user_id: canonical, + email: `${canonical}@example.test`, + }, + slack_user: { + slack_user_id: 'U_LINKED_VOICE', + display_name: 'Linked voice caller', + email: 'linked@example.test', + }, + }; + const result = await buildVoiceRequestTools(caller, 'thread-tavus-linked', { + getMemberContext: vi.fn().mockResolvedValue(memberContext), + isAdmin: vi.fn().mockResolvedValue(false), + getCommitteesLedByUser: vi.fn().mockResolvedValue(ledGroups), + }); + const committeeMutationNames = [ + 'add_committee_co_leader', + 'remove_committee_co_leader', + ]; + + expect(result.requestTools.tools.map((tool) => tool.name)) + .not.toEqual(expect.arrayContaining(committeeMutationNames)); + expect([...result.requestTools.handlers.keys()]) + .not.toEqual(expect.arrayContaining(committeeMutationNames)); + for (const name of committeeMutationNames) { + expect(result.requestTools.handlers.get(name)).toBeUndefined(); + } + }); + it('selects a bounded member domain without an implicit knowledge overlay', async () => { const router = routerFor(['member_billing']); const selected = await select(router); diff --git a/server/tests/unit/committee-leader-tool-handler-authorization.test.ts b/server/tests/unit/committee-leader-tool-handler-authorization.test.ts new file mode 100644 index 00000000000..4d3d43ac0df --- /dev/null +++ b/server/tests/unit/committee-leader-tool-handler-authorization.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getBySlackUserId: vi.fn(), + getByWorkosUserId: vi.fn(), + resolvePrincipal: vi.fn(), + mutate: vi.fn(), + invalidate: vi.fn(), +})); + +vi.mock('../../src/db/slack-db.js', () => ({ + SlackDatabase: class { + getBySlackUserId = mocks.getBySlackUserId; + getByWorkosUserId = mocks.getByWorkosUserId; + }, +})); + +vi.mock('../../src/db/working-group-db.js', () => ({ + WorkingGroupDatabase: class {}, +})); + +vi.mock('../../src/services/committee-leader-mutation.js', () => ({ + resolveCommitteeLeaderPrincipal: mocks.resolvePrincipal, + mutateCommitteeLeader: mocks.mutate, +})); + +vi.mock('../../src/addie/mcp/admin-tools.js', () => ({ + invalidateWebAdminStatusCache: mocks.invalidate, +})); + +vi.mock('../../src/slack/client.js', () => ({ inviteToChannel: vi.fn() })); + +import { createCommitteeLeaderToolHandlers } from '../../src/addie/mcp/committee-leader-tools.js'; + +function snapshot(authenticatedUserId: string, canonicalUserId: string) { + return Object.freeze({ + authenticatedUserId, + canonicalUserId, + identityId: `identity_${canonicalUserId}`, + selectedOrganizationId: null, + authorizationEpoch: '11', + credential: Object.freeze({ email: null, emailVerified: true, firstName: null, lastName: null }), + credentialGrant: null, + }); +} + +describe('committee leader Slack mutation handler authorization', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getBySlackUserId.mockResolvedValue({ + slack_user_id: 'U_AUTHENTICATED', + workos_user_id: 'credential_exact_leader', + mapping_status: 'mapped', + slack_is_deleted: false, + slack_is_bot: false, + }); + mocks.resolvePrincipal.mockResolvedValue({ + status: 'resolved', + snapshot: snapshot('credential_exact_leader', 'credential_canonical_nonleader'), + }); + mocks.mutate.mockResolvedValue({ + status: 'mutated', action: 'added', committeeId: 'committee-id', + committeeName: 'Creative working group', committeeType: 'working_group', + slackChannelId: null, targetWorkosUserId: 'target-user', + }); + }); + + it('resolves the signed Slack user to an immutable exact credential instead of MemberContext', async () => { + const handlers = createCommitteeLeaderToolHandlers( + { workos_user: { workos_user_id: 'credential_canonical_nonleader' } } as any, + 'U_AUTHENTICATED', + { surface: 'slack' }, + ); + const response = await handlers.get('add_committee_co_leader')!({ + organization_id: 'org_selected', + committee_slug: 'creative-working-group', + user_id: 'target-user', + }); + + expect(response).toContain('Successfully added'); + expect(mocks.resolvePrincipal).toHaveBeenCalledWith('credential_exact_leader'); + expect(mocks.mutate).toHaveBeenCalledWith(expect.objectContaining({ + selectedOrganizationId: 'org_selected', + surface: 'slack', + slackActorUserId: 'U_AUTHENTICATED', + principal: expect.objectContaining({ + authenticatedUserId: 'credential_exact_leader', + canonicalUserId: 'credential_canonical_nonleader', + }), + })); + }); + + it('distinguishes unavailable authority from a confirmed exact-credential denial', async () => { + const handler = createCommitteeLeaderToolHandlers(null, 'U_AUTHENTICATED', { surface: 'slack' }) + .get('remove_committee_co_leader')!; + + mocks.resolvePrincipal.mockResolvedValueOnce({ status: 'unavailable', source: 'database' }); + expect(await handler({ + organization_id: 'org_selected', committee_slug: 'creative-working-group', user_id: 'target-user', + })).toContain('temporarily unavailable'); + + mocks.resolvePrincipal.mockResolvedValueOnce({ status: 'forbidden', reason: 'credential_revoked' }); + expect(await handler({ + organization_id: 'org_selected', committee_slug: 'creative-working-group', user_id: 'target-user', + })).toContain('not authorized'); + expect(mocks.mutate).not.toHaveBeenCalled(); + }); + + it('rejects a missing organization before resolving or mutating authority', async () => { + const handler = createCommitteeLeaderToolHandlers(null, 'U_AUTHENTICATED', { surface: 'slack' }) + .get('add_committee_co_leader')!; + expect(await handler({ committee_slug: 'creative-working-group', user_id: 'target-user' })) + .toContain('explicitly select an organization_id'); + expect(mocks.resolvePrincipal).not.toHaveBeenCalled(); + expect(mocks.mutate).not.toHaveBeenCalled(); + }); + + it.each([ + ['unmapped status', { mapping_status: 'unmapped' }], + ['deleted actor', { slack_is_deleted: true }], + ['bot actor', { slack_is_bot: true }], + ['mapped-without-credential inconsistency', { workos_user_id: null }], + ])('rejects a %s before snapshot resolution', async (_label, override) => { + mocks.getBySlackUserId.mockResolvedValueOnce({ + slack_user_id: 'U_AUTHENTICATED', + workos_user_id: 'credential_exact_leader', + mapping_status: 'mapped', + slack_is_deleted: false, + slack_is_bot: false, + ...override, + }); + const handler = createCommitteeLeaderToolHandlers( + null, + 'U_AUTHENTICATED', + { surface: 'slack' }, + ).get('add_committee_co_leader')!; + + expect(await handler({ + organization_id: 'org_selected', + committee_slug: 'creative-working-group', + user_id: 'target-user', + })).toContain('not authorized'); + expect(mocks.resolvePrincipal).not.toHaveBeenCalled(); + expect(mocks.mutate).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/tavus-session-guidance-route.test.ts b/server/tests/unit/tavus-session-guidance-route.test.ts index f38a1cd0c59..2a91f0cb3b5 100644 --- a/server/tests/unit/tavus-session-guidance-route.test.ts +++ b/server/tests/unit/tavus-session-guidance-route.test.ts @@ -378,6 +378,79 @@ describe("Tavus session guidance route boundary", () => { expect([...requestTools.handlers.keys()]).toContain('create_payment_link'); }); + it.each([ + { + name: 'stored canonical A is privileged while linked exact credential B is not', + canonicalLedGroups: [{ id: 'committee-led-by-a' }], + }, + { + name: 'stored canonical A is not privileged while linked exact credential B is', + canonicalLedGroups: [], + }, + ])('refuses Tavus committee mutations over HTTP when $name', async ({ canonicalLedGroups }) => { + mocks.getThread.mockResolvedValueOnce({ + thread_id: THREAD_ID, + user_id: 'credential_a', + user_display_name: 'Linked voice caller', + channel: 'video', + context: {}, + }); + mocks.getWebMemberContext.mockResolvedValueOnce({ + is_mapped: true, + is_member: true, + slack_linked: true, + workos_user: { + workos_user_id: 'credential_a', + email: 'credential-a@example.test', + }, + slack_user: { + slack_user_id: 'U_LINKED_CREDENTIAL_B', + display_name: 'Linked credential B', + email: 'credential-b@example.test', + }, + }); + mocks.getCommitteesLedByUser.mockResolvedValueOnce(canonicalLedGroups); + const router = { + quickMatch: () => null, + route: vi.fn().mockResolvedValue({ + action: 'respond' as const, + tool_sets: ['committee_co_leaders'], + confidence: 'high' as const, + reason: 'committee leadership request', + decision_method: 'llm' as const, + }), + }; + + const response = await request(mountApp(router)) + .post('/api/addie/v1/chat/completions') + .set('Authorization', 'Bearer test-llm-secret') + .send({ + messages: [ + { role: 'system', content: `[conductor:thread_id=${THREAD_ID}] server context` }, + { role: 'user', content: 'Add a committee co-leader.' }, + ], + }); + + expect(response.status).toBe(200); + expect(mocks.getCommitteesLedByUser).toHaveBeenCalledWith('credential_a'); + const [_message, _history, requestTools, options] = mocks.processMessageStream.mock.calls[0] as [ + string, + unknown, + { tools: Array<{ name: string }>; handlers: Map }, + { allowedToolNames: string[] }, + ]; + const committeeMutationNames = [ + 'add_committee_co_leader', + 'remove_committee_co_leader', + ]; + expect(requestTools.tools.map((tool) => tool.name)) + .not.toEqual(expect.arrayContaining(committeeMutationNames)); + expect([...requestTools.handlers.keys()]) + .not.toEqual(expect.arrayContaining(committeeMutationNames)); + expect(options.allowedToolNames) + .not.toEqual(expect.arrayContaining(committeeMutationNames)); + }); + it('completes live routing before opening the SSE stream', async () => { const writes: string[] = []; let fillerWasWrittenBeforeRouting = false; From 42b31cade0e65154f7bdd87ed19c74b0997131ad Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 02:20:33 +0000 Subject: [PATCH 7/7] fix(addie): match Tavus committee tool inventory --- scripts/addie-tool-surface-budget.json | 2 +- scripts/build-addie-tool-surface-inventory.ts | 3 +- .../tool-surface-inventory.generated.json | 37 +++++++++---------- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/scripts/addie-tool-surface-budget.json b/scripts/addie-tool-surface-budget.json index 1d824a3f8e4..987a942b64c 100644 --- a/scripts/addie-tool-surface-budget.json +++ b/scripts/addie-tool-surface-budget.json @@ -1,7 +1,7 @@ { "schema_version": 4, "profile_ids_sha256": "5c4b3378b3d01fbcf75aeb0a40a6b83f460060fbc3e7ec37dc8444647d804faa", - "profile_contract_sha256": "3ceeacf8603f33b64c98eaa8f8988fc3fb0cb6107224f9e1e65c1b6e7096e207", + "profile_contract_sha256": "9254d73ac9fe7561b4af223003b4b071b8f012476f59d1236287b3287278977f", "baseline": { "id": "2026-08-26-pre-domain-consolidation", "metrics": { diff --git a/scripts/build-addie-tool-surface-inventory.ts b/scripts/build-addie-tool-surface-inventory.ts index 4e7fe065988..7f4a49bc6cc 100644 --- a/scripts/build-addie-tool-surface-inventory.ts +++ b/scripts/build-addie-tool-surface-inventory.ts @@ -1042,7 +1042,8 @@ function buildAuxiliaryProfiles(defs: Awaited ]; const tavusTailRequest = [ ...collaboration.COLLABORATION_TOOLS, - ...committee.COMMITTEE_LEADER_TOOLS, + // Keep aligned with buildVoiceRequestTools: Tavus has no immutable exact + // credential yet, so committee mutation definitions remain unavailable. ...moltbook.MOLTBOOK_TOOLS, ]; const tavusMemberRequest = [ diff --git a/server/src/addie/generated/tool-surface-inventory.generated.json b/server/src/addie/generated/tool-surface-inventory.generated.json index f151d8af527..11ecbd9c2d6 100644 --- a/server/src/addie/generated/tool-surface-inventory.generated.json +++ b/server/src/addie/generated/tool-surface-inventory.generated.json @@ -33398,41 +33398,41 @@ "id": "tavus_voice:admin:maximum_wire_schema_bytes", "runtime": "tavus_voice", "audience": "admin", - "route": "meeting_full_administration+committee_full_leadership", + "route": "meeting_full_administration+admin_organization_member_records", "selected_tool_sets": [ "meeting_full_administration", - "committee_full_leadership" + "admin_organization_member_records" ], "conditional_maximums": [ "router_selected_up_to_two_bounded_domains", "admin_event_and_meeting_permissions", "moltbook_configured" ], - "custom_tool_count": 27, + "custom_tool_count": 24, "maximum_provider_tool_count": 0, "maximum_provider_tool_names": [], "maximum_provider_tool_wire_bytes": 2, "maximum_provider_tool_wire_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "wire_schema_bytes": 27836, - "tool_reference_bytes": 5702, - "tool_reference_sha256": "8dcd4836d5397df9615fd25b6542cb7e6c5a6b200d80ccf0e1800744dee7ba44", + "wire_schema_bytes": 26275, + "tool_reference_bytes": 5732, + "tool_reference_sha256": "f9b9913e9cc168df35a1586df87122dc7cf74b12c40eb2a0e7c7a8039d9b3acf", "overridden_tool_count": 0, "conflicting_override_count": 0, "conflicting_override_names": [], "ordered_tool_names": [ - "list_working_groups", "get_account_link", "set_outreach_preference", "escalate_to_admin", "get_escalation_status", "capture_learning", + "query_admin_analytics", + "update_org_member_role", + "list_slack_users_by_org", + "list_paying_members", "list_escalations", "resolve_escalation", - "create_event", - "manage_event_registrations", - "update_event", - "check_person_event_status", - "invite_to_event", + "update_member_logo", + "update_member_profile", "schedule_meeting", "list_upcoming_meetings", "get_my_meetings", @@ -33443,13 +33443,10 @@ "update_meeting", "add_meeting_attendee", "update_topic_subscriptions", - "manage_committee_topics", - "add_committee_co_leader", - "remove_committee_co_leader", - "list_committee_co_leaders" + "manage_committee_topics" ], - "ordered_tool_names_sha256": "65b4d81ef37ff23853d42ffd1095a16982d137003ef2fc40c033161edc2b40cc", - "wire_schema_sha256": "15161c4875d729e248b27b5b33d480434a808aa4ad80bcc7d32915c85ba31a0f", + "ordered_tool_names_sha256": "598a10ba37f4c222a2bf08edc2b322b96b5b3ef9bdd3e09e6dac75a7518e3a7e", + "wire_schema_sha256": "8fc4e40b17e2dfd09f1742a62316ab01e8d9308df65f370ee08c7b456c1fd42b", "target_exception_category": "bounded_multi_domain_plan" }, { @@ -39717,11 +39714,11 @@ } }, "profile_ids_sha256": "5c4b3378b3d01fbcf75aeb0a40a6b83f460060fbc3e7ec37dc8444647d804faa", - "profile_contract_sha256": "3ceeacf8603f33b64c98eaa8f8988fc3fb0cb6107224f9e1e65c1b6e7096e207", + "profile_contract_sha256": "9254d73ac9fe7561b4af223003b4b071b8f012476f59d1236287b3287278977f", "status": "within_budget" }, "profile_contract": { "profile_ids_sha256": "5c4b3378b3d01fbcf75aeb0a40a6b83f460060fbc3e7ec37dc8444647d804faa", - "profile_contract_sha256": "3ceeacf8603f33b64c98eaa8f8988fc3fb0cb6107224f9e1e65c1b6e7096e207" + "profile_contract_sha256": "9254d73ac9fe7561b4af223003b4b071b8f012476f59d1236287b3287278977f" } }